Compare commits

..

2 Commits

Author SHA1 Message Date
sto
f91145637f Add ranking mode
Some checks failed
CI / scan_ruby (push) Successful in 20s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 12s
CI / test (push) Failing after 35s
2025-11-14 10:19:08 +01:00
sto
cdf87e48f2 Merge settings & core indexes into a single nav 2025-11-13 18:21:31 +01:00
26 changed files with 82 additions and 153 deletions

View File

@@ -2,7 +2,7 @@ class ApplicationController < ActionController::Base
include Authentication
include Pundit::Authorization
before_action :set_title, :set_current_user, :set_lang
before_action :set_current_user, :set_lang
after_action :verify_authorized
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
@@ -12,13 +12,6 @@ class ApplicationController < ActionController::Base
private
def set_title
t_action_name = action_name
t_action_name = "new" if action_name == "create"
t_action_name = "edit" if action_name == "update"
@title = I18n.t("#{controller_name}.#{t_action_name}.title")
end
def set_current_user
@current_user = current_user
end

View File

@@ -8,21 +8,11 @@ class CompletionsController < ApplicationController
def edit
authorize @contest
if @contestant
@action_name = t("helpers.buttons.back_to_contestant")
@action_path = edit_contest_contestant_path(@contest, @contestant)
end
end
def new
authorize @contest
if @contestant
@action_name = t("helpers.buttons.back_to_contestant")
@action_path = edit_contest_contestant_path(@contest, @contestant)
end
@completion = Completion.new
@completion.completed = true
if params[:contestant_id]
@@ -47,11 +37,6 @@ class CompletionsController < ApplicationController
else
if params[:completion].key?(:message_id)
@message = Message.find(params[:completion][:message_id])
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
elsif @contestant
@action_name = t("helpers.buttons.back_to_contestant")
@action_path = edit_contest_contestant_path(@contest, @contestant)
end
render :new, status: :unprocessable_entity
end
@@ -69,10 +54,6 @@ class CompletionsController < ApplicationController
redirect_to @contest, notice: t("completions.edit.notice")
end
else
if @contestant
@action_name = t("helpers.buttons.back_to_contestant")
@action_path = edit_contest_contestant_path(@contest, @contestant)
end
render :edit, status: :unprocessable_entity
end
end

View File

@@ -0,0 +1,12 @@
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
] }
end
end

View File

@@ -1,4 +1,6 @@
class ContestantsController < ApplicationController
include ContestantsConcern
before_action :set_contest
before_action :set_contestant, only: %i[ destroy edit update]
before_action :set_completions, only: %i[edit update ]
@@ -6,29 +8,17 @@ class ContestantsController < ApplicationController
def index
authorize @contest
@title = @contest.name
@contestants = @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
] }
@contestants = @contest.contestants.sort_by { |contestant| contestant.name }
filter_contestants_per_category
end
def edit
authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@title = @contestant.name
end
def new
authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@contestant = Contestant.new
end
@@ -41,8 +31,6 @@ class ContestantsController < ApplicationController
update_contestant_categories
redirect_to contest_path(@contest), notice: t("contestants.new.notice")
else
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
render :new, status: :unprocessable_entity
end
end
@@ -54,8 +42,6 @@ class ContestantsController < ApplicationController
update_contestant_categories
redirect_to @contest, notice: t("contestants.edit.notice")
else
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
render :edit, status: :unprocessable_entity
end
end
@@ -83,8 +69,6 @@ class ContestantsController < ApplicationController
if @csv_import.save
redirect_to "/contests/#{@contest.id}/import/#{@csv_import.id}"
else
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
render :import, status: :unprocessable_entity
end
end
@@ -92,8 +76,6 @@ class ContestantsController < ApplicationController
def convert_csv
authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@csv_import = CsvImport.find(params[:id])
@content = JSON.parse(@csv_import.content)
@form = Forms::CsvConversionForm.new
@@ -119,8 +101,6 @@ class ContestantsController < ApplicationController
end
redirect_to contest_path(@contest), notice: t("contestants.import.notice")
else
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
render :convert_csv, status: :unprocessable_entity
end
end
@@ -128,12 +108,7 @@ class ContestantsController < ApplicationController
def export
authorize @contest
@contestants = @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
] }
@contestants = ranked_contestants(@contest)
respond_to do |format|
format.csv do

View File

@@ -1,7 +1,8 @@
class ContestsController < ApplicationController
include CompletionsConcern
include ContestantsConcern
before_action :set_contest, only: %i[ destroy edit show update ]
before_action :set_contest, only: %i[ destroy show ]
before_action :set_settings_contest, only: %i[ settings_general_edit settings_general_update settings_offline_edit settings_offline_update settings_categories_edit ]
before_action :offline_setup, only: %i[ offline_new offline_create offline_edit offline_update offline_completed ]
skip_before_action :require_authentication, only: %i[ scoreboard offline_new offline_create offline_edit offline_update offline_completed ]
@@ -19,35 +20,16 @@ class ContestsController < ApplicationController
redirect_to contest_contestants_path(@contest)
end
def edit
authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
end
def settings_general_edit
authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@title = t("contests.edit.title")
end
def settings_offline_edit
authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@title = t("contests.edit.title")
end
def settings_categories_edit
authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@title = t("contests.edit.title")
end
def settings_general_update
@@ -56,9 +38,6 @@ class ContestsController < ApplicationController
if @contest.update(settings_general_params)
redirect_to "/contests/#{@contest.id}/settings/general", notice: t("contests.edit.notice")
else
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@title = t("contests.edit.title")
render :settings_general_edit, status: :unprocessable_entity
end
end
@@ -69,9 +48,6 @@ class ContestsController < ApplicationController
if @contest.update(settings_offline_params)
redirect_to "/contests/#{@contest.id}/settings/offline", notice: t("contests.edit.notice")
else
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@title = t("contests.edit.title")
render :settings_offline_edit, status: :unprocessable_entity
end
end
@@ -85,7 +61,7 @@ class ContestsController < ApplicationController
def create
authorize :contest
@contest = Contest.new(contest_params)
@contest = Contest.new(new_contest_params)
@contest.user_id = current_user.id
if @contest.save
redirect_to "/contests/#{@contest.id}/settings/general", notice: t("contests.new.notice")
@@ -94,18 +70,6 @@ class ContestsController < ApplicationController
end
end
def update
authorize @contest
if @contest.update(contest_params)
redirect_to @contest, notice: t("contests.edit.notice")
else
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
render :edit, status: :unprocessable_entity
end
end
def destroy
authorize @contest
@@ -124,20 +88,18 @@ class ContestsController < ApplicationController
I18n.locale = @contest.lang
@title = I18n.t("contests.scoreboard.title", name: @contest.name)
@contestants = @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
] }
@contestants = ranked_contestants(@contest)
filter_contestants_per_category
if params.key?(:hide_offline) && params[:hide_offline] == "true"
@contestants = @contestants.select { |contestant| !contestant.offline.present? }
end
@puzzles = @contest.puzzles.order(:id)
@action_name = t("helpers.buttons.refresh")
if params.key?(:category)
if params.key?(:hide_offline) && params.key?(:category)
@action_path = "/public/#{@contest.friendly_id}?hide_offline=#{params[:hide_offline]}&category=#{params[:category]}"
elsif params.key?(:category)
@action_path = "/public/#{@contest.friendly_id}?category=#{params[:category]}"
elsif params.key?(:hide_offline)
@action_path = "/public/#{@contest.friendly_id}?hide_offline=#{params[:hide_offline]}"
else
@action_path = "/public/#{@contest.friendly_id}"
end
@@ -236,12 +198,12 @@ class ContestsController < ApplicationController
@contest = Contest.find(params[:contest_id])
end
def contest_params
params.expect(contest: [ :lang, :name, :offline_form, :public, :team, :allow_registration ])
def new_contest_params
params.expect(contest: [ :name ])
end
def settings_general_params
params.expect(contest: [ :lang, :name, :public, :team, :allow_registration ])
params.expect(contest: [ :lang, :name, :public, :ranking_mode, :team, :allow_registration ])
end
def settings_offline_params

View File

@@ -79,9 +79,6 @@ class MessagesController < ApplicationController
def convert
authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@completion = Completion.new()
@completion.display_time_from_start = @message.display_time
@completion.completed = true

View File

@@ -11,16 +11,11 @@ class PuzzlesController < ApplicationController
def edit
authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
end
def new
authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@puzzle = Puzzle.new
end
@@ -32,8 +27,6 @@ class PuzzlesController < ApplicationController
if @puzzle.save
redirect_to contest_path(@contest), notice: t("puzzles.new.notice")
else
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
render :new, status: :unprocessable_entity
end
end
@@ -44,8 +37,6 @@ class PuzzlesController < ApplicationController
if @puzzle.update(puzzle_params)
redirect_to @contest, notice: t("puzzles.edit.notice")
else
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
render :edit, status: :unprocessable_entity
end
end

View File

@@ -4,11 +4,14 @@ class UsersController < ApplicationController
def index
authorize :user
@title = t("users.index.title")
@users = User.all
end
def edit
authorize @user
@title = t("users.edit.title")
end
def update

3
app/lib/ranking.rb Normal file
View File

@@ -0,0 +1,3 @@
module Ranking
AVAILABLE_RANKING_MODES = [ { id: "actual", name: I18n.t("lib.ranking.actual") }, { id: "theorical", name: I18n.t("lib.ranking.theorical") } ]
end

View File

@@ -8,6 +8,7 @@
# name :string
# offline_form :boolean default(FALSE)
# public :boolean default(FALSE)
# ranking_mode :string
# slug :string
# team :boolean default(FALSE)
# created_at :datetime not null
@@ -38,6 +39,7 @@ class Contest < ApplicationRecord
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] } }
generates_token_for :token
end

View File

@@ -41,4 +41,13 @@ javascript:
= t("puzzles.plural").capitalize
li.nav-item
a.nav-link class=active_page(contest_messages_path(@contest)) href=contest_messages_path(@contest)
= t("messages.plural").capitalize
= t("messages.plural").capitalize
li.nav-item
a.nav-link class=active_page("/contests/#{@contest.id}/settings/general") href="/contests/#{@contest.id}/settings/general"
= t("contests.form.general")
li.nav-item
a.nav-link class=active_page("/contests/#{@contest.id}/settings/offline") href="/contests/#{@contest.id}/settings/offline"
= t("contests.form.offline")
li.nav-item
a.nav-link class=active_page("/contests/#{@contest.id}/settings/categories") href="/contests/#{@contest.id}/settings/categories"
= t("contests.form.categories")

View File

@@ -1,7 +1,3 @@
.row
.col
h3 Informations
= form_with model: contestant, url: url, method: method do |form|
.row.mb-3
.col
@@ -15,7 +11,7 @@
= form.label :email
.form-text
= t("activerecord.attributes.contestant.email_description")
- if @contest.categories
- if @contest.categories && method == :patch
.row.mt-4
.col
- @contest.categories.each do |category|

View File

@@ -1,5 +1,3 @@
= render "contest_nav"
.row.mb-4 style="height: calc(100vh - 280px)"
.col.d-flex.flex-column style="height: 100%"
.row.mb-4

View File

@@ -1 +1,2 @@
h5.mb-3 = t("contestants.new.title")
= render "form", contest: @contest, contestant: @contestant, submit_text: t("helpers.buttons.add"), method: :post, url: "/contests/#{@contest.id}/contestants"

View File

@@ -1,12 +0,0 @@
.row
.col
ul.nav.nav-tabs.mb-4
li.nav-item
a.nav-link class=active_page("/contests/#{@contest.id}/settings/general") href="/contests/#{@contest.id}/settings/general"
= t("contests.form.general")
li.nav-item
a.nav-link class=active_page("/contests/#{@contest.id}/settings/offline") href="/contests/#{@contest.id}/settings/offline"
= t("contests.form.offline")
li.nav-item
a.nav-link class=active_page("/contests/#{@contest.id}/settings/categories") href="/contests/#{@contest.id}/settings/categories"
= t("contests.form.categories")

View File

@@ -1,5 +1,3 @@
= render "settings_nav"
= form_with model: Category, url: "/contests/#{@contest.id}/categories" do |form|
- if @contest.categories.size > 0
.row

View File

@@ -1,5 +1,3 @@
= render "settings_nav"
= form_with model: @contest, url: "/contests/#{@contest.id}/settings/general" do |form|
.row.mt-2.mb-3
.col
@@ -16,6 +14,11 @@
.form-check.form-switch
= form.check_box :public, class: "form-check-input"
= form.label :public
.row.mb-3
.col
.form-floating
= form.select :ranking_mode, Ranking::AVAILABLE_RANKING_MODES.map { |mode| [ mode[:name], mode[:id] ] }, {}, class: "form-select"
= form.label :ranking_mode
.row.mb-3
.col
.form-check.form-switch

View File

@@ -1,5 +1,3 @@
= render "settings_nav"
- if @contest.puzzles.length > 1
.row
.col

View File

@@ -44,9 +44,15 @@ html
= msg
h1.mb-4
= @title
- if @action_path
a.ms-4.btn.btn-primary href=@action_path style="margin-top: -6px"
= @action_name
- if @contest
= @contest.name
- if active_page("/public") == "active" && @action_path
a.ms-4.btn.btn-primary href=@action_path style="margin-top: -6px"
= t("helpers.buttons.refresh")
- else
= @title
- if @contest && active_page("/public") != "active"
= render "contest_nav"
= yield

View File

@@ -1,5 +1,3 @@
= render "contest_nav"
.row.mb-4 style="height: calc(100vh - 280px)"
.col.d-flex.flex-column style="height: 100%"

View File

@@ -1,5 +1,3 @@
= render "contest_nav"
.row.mb-4 style="height: calc(100vh - 280px)"
.col.d-flex.flex-column style="height: 100%"

View File

@@ -58,6 +58,7 @@ en:
offline_form_description: Offline participants will have to fill the form by providing an image taken of the puzzle before starting solving it, and validate their finish time with an upload of an image of the completed puzzle
offline_form_warning: Only for single-puzzle contests
public: Enable the public scoreboard
ranking_mode: Ranking mode (public scoreboard & CSV exports)
team: Team contest
team_description: For UI display purposes mainly
allow_registration: Allow registration
@@ -259,6 +260,10 @@ en:
field: Field
none: No field selected
rank: Rank
lib:
ranking:
actual: First by time if completed, then by number of pieces assembled
theorical: By time only (projected time calculated with the ppm count)
messages:
index:
no_messages: No messages received yet

View File

@@ -29,6 +29,7 @@ fr:
offline_form_description: Les participant.e.s hors-ligne pourront participer en prenant une photo du puzzle avant de le commencer, puis valider leur temps avec une photo du puzzle une fois complété
offline_form_warning: Activable uniquement pour les concours avec un seul puzzle
public: Activer le classement public
ranking_mode: Mode de classement (classement public & exports CSV)
team: Concours par équipes
team_description: Principalement pour des raisons d'affichage
allow_registration: Autoriser l'inscription via l'interface
@@ -230,6 +231,10 @@ fr:
field: Champ
none: Aucun champ sélectionné
rank: Rang
lib:
ranking:
actual: Par temps d'abord, puis par nombre de pièces assemblées
theorical: Par temps uniquement (temps projeté calculé à partir de la vitesse d'assemblage)
messages:
index:
no_messages: Pas de messages reçus pour le moment

View File

@@ -0,0 +1,5 @@
class AddRankingModeToContest < ActiveRecord::Migration[8.0]
def change
add_column :contests, :ranking_mode, :string
end
end

3
db/schema.rb generated
View File

@@ -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_10_110151) do
ActiveRecord::Schema[8.0].define(version: 2025_11_14_085123) do
create_table "active_storage_attachments", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
@@ -95,6 +95,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_10_110151) do
t.string "lang", default: "en"
t.boolean "public", default: false
t.boolean "offline_form", default: false
t.string "ranking_mode"
t.index ["slug"], name: "index_contests_on_slug", unique: true
t.index ["user_id"], name: "index_contests_on_user_id"
end

View File

@@ -8,6 +8,7 @@
# name :string
# offline_form :boolean default(FALSE)
# public :boolean default(FALSE)
# ranking_mode :string
# slug :string
# team :boolean default(FALSE)
# created_at :datetime not null