diff --git a/app/controllers/contests_controller.rb b/app/controllers/contests_controller.rb index 55014fe..d71f010 100644 --- a/app/controllers/contests_controller.rb +++ b/app/controllers/contests_controller.rb @@ -3,7 +3,7 @@ class ContestsController < ApplicationController include ContestantsConcern before_action :set_contest, only: %i[ destroy show ] - before_action :set_settings_contest, only: %i[ settings_general_edit settings_general_update settings_public_edit settings_public_update settings_onsite_edit settings_onsite_update settings_online_edit settings_online_update settings_categories_edit ] + before_action :set_settings_contest, only: %i[ stopwatch stopwatch_continue stopwatch_pause stopwatch_reset stopwatch_start settings_general_edit settings_general_update settings_public_edit settings_public_update settings_onsite_edit settings_onsite_update settings_online_edit settings_online_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 ] @@ -80,6 +80,47 @@ class ContestsController < ApplicationController end end + def stopwatch + authorize @contest + end + + def stopwatch_continue + authorize @contest + + pause_duration = Time.now() - @contest.pause_time + @contest.start_time = @contest.start_time + pause_duration + @contest.pause_time = nil + @contest.save + redirect_to "/contests/#{@contest.id}/stopwatch" + end + + def stopwatch_pause + authorize @contest + authorize @contest + + @contest.pause_time = Time.now() + @contest.save + redirect_to "/contests/#{@contest.id}/stopwatch" + end + + def stopwatch_reset + authorize @contest + + @contest.start_time = nil + @contest.pause_time = nil + @contest.save + redirect_to "/contests/#{@contest.id}/stopwatch" + end + + def stopwatch_start + authorize @contest + + @contest.start_time = Time.now() + @contest.pause_time = nil + @contest.save + redirect_to "/contests/#{@contest.id}/stopwatch" + end + def new authorize :contest @@ -241,7 +282,7 @@ class ContestsController < ApplicationController end def settings_public_params - params.expect(contest: [ :public, :ranking_mode ]) + params.expect(contest: [ :public, :ranking_mode, :show_stopwatch ]) end def settings_onsite_params diff --git a/app/models/contest.rb b/app/models/contest.rb index 7a60dbd..1b4ded9 100644 --- a/app/models/contest.rb +++ b/app/models/contest.rb @@ -10,9 +10,12 @@ # lang :string default("en") # name :string # offline_form :boolean default(FALSE) +# pause_time :datetime # public :boolean default(FALSE) # ranking_mode :string +# show_stopwatch :boolean # slug :string +# start_time :datetime # team :boolean default(FALSE) # created_at :datetime not null # updated_at :datetime not null diff --git a/app/policies/contest_policy.rb b/app/policies/contest_policy.rb index d26a6cb..d153826 100644 --- a/app/policies/contest_policy.rb +++ b/app/policies/contest_policy.rb @@ -75,6 +75,26 @@ class ContestPolicy < ApplicationPolicy edit? end + def stopwatch? + edit? + end + + def stopwatch_continue? + edit? + end + + def stopwatch_pause? + edit? + end + + def stopwatch_reset? + edit? + end + + def stopwatch_start? + edit? + end + def finalize_import? owner_or_admin end diff --git a/app/views/application/_contest_nav.html.slim b/app/views/application/_contest_nav.html.slim index b442ced..1f6ce80 100644 --- a/app/views/application/_contest_nav.html.slim +++ b/app/views/application/_contest_nav.html.slim @@ -7,6 +7,9 @@ li.nav-item a.nav-link class=active_page(contest_puzzles_path(@contest)) href=contest_puzzles_path(@contest) = t("puzzles.plural").capitalize + li.nav-item + a.nav-link class=active_page("/contests/#{@contest.id}/stopwatch") href="/contests/#{@contest.id}/stopwatch" + = t("contests.nav.stopwatch").capitalize li.nav-item a.nav-link class=active_page(contest_messages_path(@contest)) href=contest_messages_path(@contest) = t("messages.plural").capitalize diff --git a/app/views/contests/_scoreboard_desktop_marathon.html.slim b/app/views/contests/_scoreboard_desktop_marathon.html.slim index e9dcdfe..3233ec2 100644 --- a/app/views/contests/_scoreboard_desktop_marathon.html.slim +++ b/app/views/contests/_scoreboard_desktop_marathon.html.slim @@ -8,6 +8,9 @@ thead tr th + - if @contest.show_stopwatch + .stopwatch id="display-time" style="font-size: 60px; font-weight: 400;" + = render "stopwatch_js" th th th diff --git a/app/views/contests/_scoreboard_desktop_single.html.slim b/app/views/contests/_scoreboard_desktop_single.html.slim index 95b3b76..31addcb 100644 --- a/app/views/contests/_scoreboard_desktop_single.html.slim +++ b/app/views/contests/_scoreboard_desktop_single.html.slim @@ -1,5 +1,5 @@ .row - .mt-3.col-6.d-flex.flex-column style="height: calc(100vh - 250px)" + .mt-3.col-6.d-flex.flex-column style="height: calc(100vh - 310px)" .d-flex.flex-column style="overflow-y: auto" table.table.table-striped.table-hover thead diff --git a/app/views/contests/_scoreboard_mobile_single.html.slim b/app/views/contests/_scoreboard_mobile_single.html.slim index 047721b..947661a 100644 --- a/app/views/contests/_scoreboard_mobile_single.html.slim +++ b/app/views/contests/_scoreboard_mobile_single.html.slim @@ -3,14 +3,14 @@ css: .row - if @puzzles.size > 0 - .d-flex.flex-column.justify-content-center.mb-5 + .d-flex.flex-column.justify-content-center.mb-2 = image_tag(@puzzles[0].image, style: "max-height: 200px; object-fit: contain") if @puzzles[0].image.attached? .mt-2.fs-6 style="text-align: center" => "#{@puzzles[0].name} -" = "#{@puzzles[0].brand} #{@puzzles[0].pieces}p" .row - .mt-3.d-flex.flex-column style="height: calc(100vh - 250px)" + .mt-3.d-flex.flex-column .d-flex.flex-column style="overflow-y: auto" table.table.table-striped.table-hover thead diff --git a/app/views/contests/_stopwatch_js.html.slim b/app/views/contests/_stopwatch_js.html.slim new file mode 100644 index 0000000..3eb181b --- /dev/null +++ b/app/views/contests/_stopwatch_js.html.slim @@ -0,0 +1,22 @@ +javascript: + startTime = #{@contest.start_time.present? ? @contest.start_time.to_i : "null"}; + pauseTime = #{@contest.pause_time.present? ? @contest.pause_time.to_i : "null"}; + function updateTime() { + const displayTimeEl = document.getElementById('display-time'); + if (displayTimeEl) { + if (startTime) { + let s = Math.floor((Date.now() - 1000 * startTime) / 1000); + if (pauseTime) s = Math.floor(pauseTime - startTime); + let ss = s % 60; + let mm = Math.floor(s / 60) % 60; + let hh = Math.floor(s / 3600); + displayTimeEl.innerHTML = `${hh < 10 ? `0${hh}` : hh}:${mm < 10 ? `0${mm}` : mm}:${ss < 10 ? `0${ss}` : ss}`; + setTimeout(updateTime, 1000); + } else { + displayTimeEl.innerHTML = '00:00:00'; + } + } else { + setTimeout(updateTime, 20); + } + } + setTimeout(updateTime, 1); \ No newline at end of file diff --git a/app/views/contests/scoreboard.html.slim b/app/views/contests/scoreboard.html.slim index c4c3f40..4da4c33 100644 --- a/app/views/contests/scoreboard.html.slim +++ b/app/views/contests/scoreboard.html.slim @@ -3,12 +3,16 @@ css: .mobile-single { display: block !important; } .desktop-single { display: none; } #scoreboard-switches { display: none; } + .stopwatch { font-size: 50px !important; } } - if @contest.puzzles.size < 2 = render "selectors" turbo-frame id="scoreboard" + - if @contest.show_stopwatch + .stopwatch id="display-time" style="font-size: 60px; font-weight: 400; margin-bottom: 0; text-align: center;" + = render "stopwatch_js" a.btn.btn-primary href="" id="refresh-button" style="display: none;" .mobile-single style="display: none;" = render "scoreboard_mobile_single" diff --git a/app/views/contests/settings_public_edit.html.slim b/app/views/contests/settings_public_edit.html.slim index fa89bb9..522ef6f 100644 --- a/app/views/contests/settings_public_edit.html.slim +++ b/app/views/contests/settings_public_edit.html.slim @@ -6,6 +6,11 @@ .form-check.form-switch = form.check_box :public, class: "form-check-input" = form.label :public + .row.mt-2 + .col + .form-check.form-switch + = form.check_box :show_stopwatch, class: "form-check-input" + = form.label :show_stopwatch .row.mt-3 .col .form-floating diff --git a/app/views/contests/stopwatch.html.slim b/app/views/contests/stopwatch.html.slim new file mode 100644 index 0000000..347b566 --- /dev/null +++ b/app/views/contests/stopwatch.html.slim @@ -0,0 +1,16 @@ +.row + .col + .alert.alert-primary + = t("contests.stopwatch.info") +h1.mt-3 id="display-time" style="font-size: 80px;" += render "stopwatch_js" +.row.mt-3 + .col.d-flex + - if !@contest.start_time.present? + = button_to t("helpers.buttons.stopwatch_start"), "/contests/#{@contest.id}/stopwatch_start", method: :post, class: "btn btn-primary" + - if @contest.pause_time.present? + = button_to t("helpers.buttons.stopwatch_continue"), "/contests/#{@contest.id}/stopwatch_continue", method: :post, class: "btn btn-primary" + - if @contest.start_time.present? && !@contest.pause_time.present? + = button_to t("helpers.buttons.stopwatch_pause"), "/contests/#{@contest.id}/stopwatch_pause", method: :post, class: "btn btn-primary" + - if @contest.start_time.present? + = button_to t("helpers.buttons.stopwatch_reset"), "/contests/#{@contest.id}/stopwatch_reset", method: :post, class: "ms-3 btn btn-warning" \ No newline at end of file diff --git a/config/locales/en.yml b/config/locales/en.yml index cfe2a22..a77e229 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -65,6 +65,7 @@ en: offline_form_warning: Only for single-puzzle contests public: Enable the public scoreboard ranking_mode: Ranking mode (public scoreboard & CSV exports) + show_stopwatch: Display the stopwatch team: Team contest team_description: For UI display purposes mainly allow_registration: Allow registration @@ -206,12 +207,13 @@ en: onsite: Onsite contests public: Public scoreboard settings: Settings + stopwatch: Stopwatch new: notice: Contest added title: New jigsaw puzzle contest scoreboard: all_categories: All categories - auto_refresh: Auto refresh + auto_refresh: Auto refresh (30s) hide_offline: Hide offline participants refresh: Activate auto-refresh (every 5s) title: "%{name}" @@ -225,6 +227,8 @@ en: offline_form_disabled: The offline form is disabled public_scoreboard_disabled: The public scoreboard is disabled url_copied: URL copied to the clipboard + stopwatch: + info: This stopwatch is used for display in the public scoreboard, when allowed in the settings contestants: convert_csv: title: Import participants @@ -287,6 +291,10 @@ en: sign_in: Sign in save: Save start: Click here to start your participation + stopwatch_continue: Continue + stopwatch_pause: Pause + stopwatch_reset: Reset + stopwatch_start: Start update: Save modifications field: Field none: No field selected diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 04604b3..513c335 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -36,6 +36,7 @@ fr: 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) + show_stopwatch: Afficher le chronomètre team: Concours par équipes team_description: Principalement pour des raisons d'affichage allow_registration: Autoriser l'inscription via l'interface @@ -177,12 +178,13 @@ fr: onsite: Concours en présentiel public: Classement public settings: Paramètres + stopwatch: Chronomètre new: notice: Concours ajouté title: Nouveau concours scoreboard: all_categories: Toutes les catégories - auto_refresh: Auto-rafraichissement + auto_refresh: Auto-rafraichissement (30s) hide_offline: Cacher les participant.e.s hors-ligne refresh: Activer le rafraichissement automatique de la page (toutes les 5s) title: "%{name}" @@ -196,6 +198,8 @@ fr: offline_form_disabled: Le formulaire hors-ligne n'est pas activé public_scoreboard_disabled: Le classement public n'est pas activé url_copied: L’URL a été copiée dans le presse-papier + stopwatch: + info: Ce chronomètre est utilisé pour être affiché dans le classement public, quand autorisé dans les paramètres contestants: convert_csv: title: Importer des participant.e.s @@ -258,6 +262,10 @@ fr: sign_in: Se connecter save: Modifier start: Clique ici pour démarrer ta participation + stopwatch_continue: Reprendre + stopwatch_pause: Pause + stopwatch_reset: Ré-initialiser + stopwatch_start: Démarrer update: Enregistrer les modifications field: Champ none: Aucun champ sélectionné diff --git a/config/routes.rb b/config/routes.rb index dc23263..c4e5267 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -18,6 +18,11 @@ Rails.application.routes.draw do get "settings/online", to: "contests#settings_online_edit" patch "settings/online", to: "contests#settings_online_update" get "settings/categories", to: "contests#settings_categories_edit" + get "stopwatch", to: "contests#stopwatch" + post "stopwatch_continue", to: "contests#stopwatch_continue" + post "stopwatch_pause", to: "contests#stopwatch_pause" + post "stopwatch_reset", to: "contests#stopwatch_reset" + post "stopwatch_start", to: "contests#stopwatch_start" resources :categories, only: [ :create, :destroy ] resources :completions resources :contestants diff --git a/db/migrate/20251209073711_add_start_time_to_contest.rb b/db/migrate/20251209073711_add_start_time_to_contest.rb new file mode 100644 index 0000000..f0626d1 --- /dev/null +++ b/db/migrate/20251209073711_add_start_time_to_contest.rb @@ -0,0 +1,5 @@ +class AddStartTimeToContest < ActiveRecord::Migration[8.0] + def change + add_column :contests, :start_time, :datetime + end +end diff --git a/db/migrate/20251209075739_add_pause_time_to_contest.rb b/db/migrate/20251209075739_add_pause_time_to_contest.rb new file mode 100644 index 0000000..24f7654 --- /dev/null +++ b/db/migrate/20251209075739_add_pause_time_to_contest.rb @@ -0,0 +1,5 @@ +class AddPauseTimeToContest < ActiveRecord::Migration[8.0] + def change + add_column :contests, :pause_time, :datetime + end +end diff --git a/db/migrate/20251209081941_add_show_stopwatch_to_contest.rb b/db/migrate/20251209081941_add_show_stopwatch_to_contest.rb new file mode 100644 index 0000000..cd077de --- /dev/null +++ b/db/migrate/20251209081941_add_show_stopwatch_to_contest.rb @@ -0,0 +1,5 @@ +class AddShowStopwatchToContest < ActiveRecord::Migration[8.0] + def change + add_column :contests, :show_stopwatch, :boolean + end +end diff --git a/db/schema.rb b/db/schema.rb index 563fa05..2e8690b 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_12_04_100550) do +ActiveRecord::Schema[8.0].define(version: 2025_12_09_081941) do create_table "active_storage_attachments", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -103,6 +103,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_04_100550) do t.string "duration" t.integer "duration_seconds" t.string "code" + t.datetime "start_time" + t.datetime "pause_time" + t.boolean "show_stopwatch" 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 b4c254c..7460ea5 100644 --- a/spec/factories/contests.rb +++ b/spec/factories/contests.rb @@ -10,9 +10,12 @@ # lang :string default("en") # name :string # offline_form :boolean default(FALSE) +# pause_time :datetime # public :boolean default(FALSE) # ranking_mode :string +# show_stopwatch :boolean # slug :string +# start_time :datetime # team :boolean default(FALSE) # created_at :datetime not null # updated_at :datetime not null