From f4136ea58ad5ce33bef4fdf3c7938671852648e7 Mon Sep 17 00:00:00 2001 From: sto Date: Mon, 10 Nov 2025 12:26:48 +0100 Subject: [PATCH] Implement missing & remaining pieces propagation + cleaner forms --- app/controllers/completions_controller.rb | 3 +- app/controllers/contests_controller.rb | 17 ++++-- app/models/completion.rb | 14 ++++- app/models/offline.rb | 17 ++++-- app/views/completions/_form.html.slim | 44 ++++++++++++++- app/views/contestants/_form.html.slim | 8 +++ app/views/contests/offline_edit.html.slim | 56 +++++++++++++++---- config/locales/en.yml | 15 +++-- config/locales/fr.yml | 17 ++++-- ...100516_add_missing_pieces_to_completion.rb | 5 ++ ...51110103247_add_completed_to_completion.rb | 5 ++ ...20251110110151_add_submitted_to_offline.rb | 5 ++ db/schema.rb | 5 +- spec/factories/offlines.rb | 1 + spec/models/offline_spec.rb | 1 + 15 files changed, 175 insertions(+), 38 deletions(-) create mode 100644 db/migrate/20251110100516_add_missing_pieces_to_completion.rb create mode 100644 db/migrate/20251110103247_add_completed_to_completion.rb create mode 100644 db/migrate/20251110110151_add_submitted_to_offline.rb diff --git a/app/controllers/completions_controller.rb b/app/controllers/completions_controller.rb index 02edb08..810a700 100644 --- a/app/controllers/completions_controller.rb +++ b/app/controllers/completions_controller.rb @@ -24,6 +24,7 @@ class CompletionsController < ApplicationController end @completion = Completion.new + @completion.completed = true if params[:contestant_id] @completion.contestant_id = params[:contestant_id] end @@ -111,6 +112,6 @@ class CompletionsController < ApplicationController end def completion_params - params.expect(completion: [ :display_time_from_start, :remaining_pieces, :contestant_id, :message_id, :puzzle_id ]) + params.expect(completion: [ :display_time_from_start, :completed, :missing_pieces, :remaining_pieces, :contestant_id, :message_id, :puzzle_id ]) end end diff --git a/app/controllers/contests_controller.rb b/app/controllers/contests_controller.rb index d28dbe1..418b8bb 100644 --- a/app/controllers/contests_controller.rb +++ b/app/controllers/contests_controller.rb @@ -131,7 +131,7 @@ class ContestsController < ApplicationController not_found and return end - if @offline.images.length > 1 + if @offline.submitted render :offline_already_submitted and return end end @@ -144,11 +144,15 @@ class ContestsController < ApplicationController not_found and return end - @offline.completed = true + @offline.submitted = true + @offline.completed = params[:offline][:completed] @offline.end_time = Time.now() @offline.images.attach(params[:offline][:end_image]) - @offline.missing_pieces = params[:offline][:missing_pieces] - @offline.remaining_pieces = params[:offline][:remaining_pieces] + if @offline.completed + @offline.missing_pieces = params[:offline][:missing_pieces] + else + @offline.remaining_pieces = params[:offline][:remaining_pieces] + end if @offline.save if @contest.puzzles.length > 0 dp = display_time(@offline.end_time.to_i - @offline.start_time.to_i) @@ -156,7 +160,10 @@ class ContestsController < ApplicationController Completion.create(contest: @contest, contestant: contestant, puzzle: @contest.puzzles[0], - display_time_from_start: dp) + completed: @offline.completed, + display_time_from_start: dp, + missing_pieces: @offline.missing_pieces, + remaining_pieces: @offline.remaining_pieces) extend_completions!(contestant) end redirect_to "/public/#{@contest.friendly_id}/offline/#{@offline.generate_token_for(:token)}/completed" diff --git a/app/models/completion.rb b/app/models/completion.rb index 43e557c..651a22a 100644 --- a/app/models/completion.rb +++ b/app/models/completion.rb @@ -3,8 +3,10 @@ # Table name: completions # # id :integer not null, primary key +# completed :boolean # display_relative_time :string # display_time_from_start :string +# missing_pieces :integer # remaining_pieces :integer # time_seconds :integer # created_at :datetime not null @@ -36,8 +38,10 @@ class Completion < ApplicationRecord before_save :add_time_seconds, if: -> { display_time_from_start.present? } before_save :nullify_display_time + before_save :clean_pieces - 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: -> { remaining_pieces == nil } + 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 } + 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 } validates :remaining_pieces, numericality: { only_integer: true }, if: -> { remaining_pieces.present? } @@ -66,4 +70,12 @@ class Completion < ApplicationRecord self.time_seconds = arr[0].to_i end end + + def clean_pieces + if self.completed + self.remaining_pieces = nil + else + self.missing_pieces = nil + end + end end diff --git a/app/models/offline.rb b/app/models/offline.rb index 0f89c6a..9481ccb 100644 --- a/app/models/offline.rb +++ b/app/models/offline.rb @@ -9,6 +9,7 @@ # name :string not null # remaining_pieces :integer # start_time :datetime not null +# submitted :boolean # created_at :datetime not null # updated_at :datetime not null # contest_id :integer not null @@ -32,7 +33,10 @@ class Offline < ApplicationRecord generates_token_for :token + before_save :clean_pieces + validates :name, presence: true + validates :remaining_pieces, presence: true, if: -> { submitted && !completed } validates :start_time, presence: true validate :end_image_is_present @@ -56,10 +60,7 @@ class Offline < ApplicationRecord end def end_image_is_present - logger = Logger.new(STDOUT) - logger.info(self.missing_pieces) - logger.info(self.missing_pieces.present?) - if self.completed && self.images.length < 2 + if self.submitted && self.images.length < 2 errors.add(:end_image, I18n.t("activerecord.errors.models.offline.attributes.end_image.blank")) end end @@ -69,4 +70,12 @@ class Offline < ApplicationRecord errors.add(:images, I18n.t("activerecord.errors.models.offline.attributes.start_image.blank")) end end + + def clean_pieces + if self.completed + self.remaining_pieces = nil + else + self.missing_pieces = nil + end + end end diff --git a/app/views/completions/_form.html.slim b/app/views/completions/_form.html.slim index 96e7696..a9511d6 100644 --- a/app/views/completions/_form.html.slim +++ b/app/views/completions/_form.html.slim @@ -32,17 +32,55 @@ - else = form.hidden_field :puzzle_id .row.mb-3 + .col + .form-check.form-switch + = form.check_box :completed, class: "form-check-input" + = form.label :completed + javascript: + completedEl = document.getElementById('completion_completed'); + completedEl.addEventListener('change', (e) => { + const timeEl = document.getElementById('time'); + const missingPiecesEl = document.getElementById('missing_pieces'); + const remainingPiecesEl = document.getElementById('remaining_pieces'); + if (e.target.checked) { + timeEl.style.display = 'block'; + missingPiecesEl.style.display = 'block'; + remainingPiecesEl.style.display = 'none'; + } else { + timeEl.style.display = 'none'; + missingPiecesEl.style.display = 'none'; + remainingPiecesEl.style.display = 'block'; + } + }) + .row.mb-3 id="time" .col .form-floating = form.text_field :display_time_from_start, autocomplete: "off", class: "form-control" = form.label :display_time_from_start, class: "required" - .row.mb-3 + .row.mb-3 id="missing_pieces" + .col + .form-floating + = form.text_field :missing_pieces, autocomplete: "off", class: "form-control" + = form.label :missing_pieces + .row.mb-3 id="remaining_pieces" style="display: none;" .col .form-floating = form.text_field :remaining_pieces, autocomplete: "off", class: "form-control" = form.label :remaining_pieces - .form-text - = t("activerecord.attributes.completion.remaining_pieces_description") + javascript: + completedEl = document.getElementById('completion_completed'); + timeEl = document.getElementById('time'); + missingPiecesEl = document.getElementById('missing_pieces'); + remainingPiecesEl = document.getElementById('remaining_pieces'); + if (completedEl.checked) { + timeEl.style.display = 'block'; + missingPiecesEl.style.display = 'block'; + remainingPiecesEl.style.display = 'none'; + } else { + timeEl.style.display = 'none'; + missingPiecesEl.style.display = 'none'; + remainingPiecesEl.style.display = 'block'; + } .row .col = form.submit submit_text, class: "btn btn-primary" \ No newline at end of file diff --git a/app/views/contestants/_form.html.slim b/app/views/contestants/_form.html.slim index 8925e76..c5fa0ac 100644 --- a/app/views/contestants/_form.html.slim +++ b/app/views/contestants/_form.html.slim @@ -33,6 +33,10 @@ .row.mt-5 .col h3 Completions + .row + .col + .alert.alert-info + = t("contestants.edit.completions_note") table.table.table-striped.table-hover thead tr @@ -44,6 +48,8 @@ - else th scope="col" = t("activerecord.attributes.completion.display_time") + th scope="col" + = t("activerecord.attributes.completion.missing_pieces") th scope="col" = t("activerecord.attributes.completion.remaining_pieces") th scope="col" @@ -56,6 +62,8 @@ - if @contest.puzzles.size > 1 td = completion.display_relative_time + td + = completion.missing_pieces td = completion.remaining_pieces td diff --git a/app/views/contests/offline_edit.html.slim b/app/views/contests/offline_edit.html.slim index 54e3920..2bae352 100644 --- a/app/views/contests/offline_edit.html.slim +++ b/app/views/contests/offline_edit.html.slim @@ -1,19 +1,22 @@ = form_with model: @offline, url: "/public/#{@contest.friendly_id}/offline/#{@offline.generate_token_for(:token)}" do |form| - = form.hidden_field :completed - h3 = t("offlines.form.start_message") + h3 = t("offlines.form.start_message", name: @offline.name) h1 id="display-time" style="font-size: 80px;" javascript: - const startTime = #{@offline.start_time.to_i}; + startTime = #{@offline.start_time.to_i}; function updateTime() { const displayTimeEl = document.getElementById('display-time'); - const s = Math.floor((Date.now() - 1000 * startTime) / 1000); - 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); + if (displayTimeEl) { + const s = Math.floor((Date.now() - 1000 * startTime) / 1000); + 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 { + setTimeout(updateTime, 20); + } } - setTimeout(updateTime, 50); + setTimeout(updateTime, 1); .row.mt-5.mb-3 .col .form-text.mb-1 @@ -35,20 +38,49 @@ } setMaxUploadSize(); - .row.mt-4.mb-3 + .row.mb-3 + .col + .form-check.form-switch + = form.check_box :completed, class: "form-check-input" + = form.label :completed + javascript: + completedEl = document.getElementById('offline_completed'); + completedEl.addEventListener('change', (e) => { + missingPiecesEl = document.getElementById('missing_pieces'); + remainingPiecesEl = document.getElementById('remaining_pieces'); + if (e.target.checked) { + missingPiecesEl.style.display = 'block'; + remainingPiecesEl.style.display = 'none'; + } else { + missingPiecesEl.style.display = 'none'; + remainingPiecesEl.style.display = 'block'; + } + }) + .row.mb-3 id="missing_pieces" style="display: none;" .col .form-floating = form.text_field :missing_pieces, autocomplete: "off", class: "form-control" = form.label :missing_pieces .form-text = t("offlines.form.missing_pieces") - .row.mb-3 + .row.mb-3 id="remaining_pieces" .col .form-floating = form.text_field :remaining_pieces, autocomplete: "off", class: "form-control" = form.label :remaining_pieces .form-text = t("offlines.form.remaining_pieces") + javascript: + completedEl = document.getElementById('offline_completed'); + missingPiecesEl = document.getElementById('missing_pieces'); + remainingPiecesEl = document.getElementById('remaining_pieces'); + if (completedEl.checked) { + missingPiecesEl.style.display = 'block'; + remainingPiecesEl.style.display = 'none'; + } else { + missingPiecesEl.style.display = 'none'; + remainingPiecesEl.style.display = 'block'; + } .row.mt-4 .col = form.submit t("helpers.buttons.end"), class: "btn btn-primary" \ No newline at end of file diff --git a/config/locales/en.yml b/config/locales/en.yml index 75199e9..11ab007 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -43,13 +43,14 @@ en: new: New category name: Category completion: + completed: Puzzle completed contestant: Participant display_time: Time display_time_from_start: Time since start display_relative_time: Time for this puzzle puzzle: Puzzle - remaining_pieces: Remaining pieces - remaining_pieces_description: When this field is filled, the above time will not be taken into account + missing_pieces: Missing pieces + remaining_pieces: Remaining pieces (not completed puzzle) contest: lang: Language for the public scoreboard name: Name @@ -77,6 +78,7 @@ en: text: Content time: Time offline: + completed: Puzzle completed name: Your name missing_pieces: Missing pieces remaining_pieces: Remaining pieces @@ -105,6 +107,7 @@ en: puzzle_id: taken: "This contestant has already completed this puzzle" remaining_pieces: + blank: This is required not_an_integer: This is not an integer not_a_number: This is not an integer contest: @@ -131,6 +134,7 @@ en: name: blank: Please enter a name for your participation remaining_pieces: + blank: You need to provide the number of remaining pieces to assemble not_an_integer: This is not an integer not_a_number: This is not an integer start_image: @@ -200,6 +204,7 @@ en: destroy: notice: Participant deleted edit: + completions_note: The time doesn't automatically account penalties for missing pieces. The ability to specify time penalties will be added later on, stay tuned! end_image: End image notice: Participant updated not_finished: Not yet finished @@ -268,10 +273,10 @@ en: already_submitted: You have already completed the puzzle completed_message: Thanks for your participation! end_image_select: Take a photo of your completed puzzle, or on the state it is if you decide to give up - missing_pieces: If completed, indicate the number of missing pieces, if any - remaining_pieces: If your puzzle isn't complete, indicate here the number of remaining pieces to assemble + missing_pieces: Indicate the number of missing pieces, if any + remaining_pieces: Indicate the number of remaining pieces to assemble start_image_select: Take a photo of the puzzle with the provided code written on a paper before starting it - start_message: Let's go! + start_message: Let's go %{name}! puzzles: destroy: notice: Puzzle deleted diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 39d559e..27357c8 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -14,13 +14,14 @@ fr: new: Nouvelle catégorie name: Catégorie completion: + completed: Puzzle terminé contestant_id: Participant.e display_time: Temps display_time_from_start: Temps depuis le début display_relative_time: Temps pour ce puzzle puzzle: Puzzle - remaining_pieces: Nombre de pièces restantes - remaining_pieces_description: Si ce champ est rempli, le temps ci-dessus ne sera pas pris en compte + missing_pieces: Pièces manquantes + remaining_pieces: Pièces restantes (puzzle non fini) contest: lang: Langue pour le classement public name: Nom @@ -48,6 +49,7 @@ fr: text: Contenu time: Temps offline: + completed: Puzzle terminé name: Ton nom ou pseudo missing_pieces: Pièces manquantes remaining_pieces: Pièces restantes @@ -76,6 +78,7 @@ fr: puzzle_id: taken: "Ce.tte participant.e a déjà complété ce puzzle" remaining_pieces: + blank: Ce champ est obligatoire not_an_integer: Ce n'est pas un nombre entier not_a_number: Ce n'est pas un nombre entier contest: @@ -95,13 +98,14 @@ fr: offline: attributes: end_image: - blank: Tu dois inclure cette image pour pouvoir valider ton puzzle complété + blank: Tu dois inclure cette image pour pouvoir valider ta participation missing_pieces: not_an_integer: Ce n'est pas un entier not_a_number: Ce n'est pas un entier name: blank: Tu dois entrer un nom pour pouvoir participer remaining_pieces: + blank: Tu dois renseigner le nombre de pièces restantes à assembler not_an_integer: Ce n'est pas un entier not_a_number: Ce n'est pas un entier start_image: @@ -171,6 +175,7 @@ fr: destroy: notice: Participant.e supprimé.e edit: + completions_note: Le temps n'inclut actuellement pas de pénalité pour les pièces manquantes. La possibilité de spécifier des pénalités en temps sera ajouté plus tard à l'interface ! end_image: Image de fin notice: Participant.e modifié.e not_finished: Non terminé @@ -239,10 +244,10 @@ fr: already_submitted: Tu as déjà complété ton puzzle completed_message: Merci pour ta participation ! end_image_select: Prends une photo du puzzle une fois complété, ou de l'état actuel si tu choisis de t'arrêter là - missing_pieces: Si complété, indique le nombre de pièces manquantes s'il y en a - remaining_pieces: Si tu as choisis de t'arrêter avant la fin du puzzle, indique ici le nombre de pièces restantes à assembler + missing_pieces: Indique le nombre de pièces manquantes s'il y en a + remaining_pieces: Indique ici le nombre de pièces restantes à assembler start_image_select: Prends une photo du puzzle avant de le commencer, avec le code donné par l'organisateur.ice écrit sur du papier - start_message: C'est parti ! + start_message: C'est parti %{name} ! puzzles: destroy: notice: Puzzle supprimé diff --git a/db/migrate/20251110100516_add_missing_pieces_to_completion.rb b/db/migrate/20251110100516_add_missing_pieces_to_completion.rb new file mode 100644 index 0000000..cc0a6cc --- /dev/null +++ b/db/migrate/20251110100516_add_missing_pieces_to_completion.rb @@ -0,0 +1,5 @@ +class AddMissingPiecesToCompletion < ActiveRecord::Migration[8.0] + def change + add_column :completions, :missing_pieces, :integer + end +end diff --git a/db/migrate/20251110103247_add_completed_to_completion.rb b/db/migrate/20251110103247_add_completed_to_completion.rb new file mode 100644 index 0000000..f966eea --- /dev/null +++ b/db/migrate/20251110103247_add_completed_to_completion.rb @@ -0,0 +1,5 @@ +class AddCompletedToCompletion < ActiveRecord::Migration[8.0] + def change + add_column :completions, :completed, :boolean + end +end diff --git a/db/migrate/20251110110151_add_submitted_to_offline.rb b/db/migrate/20251110110151_add_submitted_to_offline.rb new file mode 100644 index 0000000..e20f12c --- /dev/null +++ b/db/migrate/20251110110151_add_submitted_to_offline.rb @@ -0,0 +1,5 @@ +class AddSubmittedToOffline < ActiveRecord::Migration[8.0] + def change + add_column :offlines, :submitted, :boolean + end +end diff --git a/db/schema.rb b/db/schema.rb index e0b9923..d62b282 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_08_082751) do +ActiveRecord::Schema[8.0].define(version: 2025_11_10_110151) do create_table "active_storage_attachments", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -65,6 +65,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_08_082751) do t.string "display_relative_time" t.integer "message_id" t.integer "remaining_pieces" + t.integer "missing_pieces" + t.boolean "completed" t.index ["contest_id"], name: "index_completions_on_contest_id" t.index ["contestant_id"], name: "index_completions_on_contestant_id" t.index ["message_id"], name: "index_completions_on_message_id" @@ -137,6 +139,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_08_082751) do t.integer "contestant_id" t.integer "missing_pieces" t.integer "remaining_pieces" + t.boolean "submitted" t.index ["contest_id"], name: "index_offlines_on_contest_id" t.index ["contestant_id"], name: "index_offlines_on_contestant_id" end diff --git a/spec/factories/offlines.rb b/spec/factories/offlines.rb index 2bc58ab..943a47e 100644 --- a/spec/factories/offlines.rb +++ b/spec/factories/offlines.rb @@ -9,6 +9,7 @@ # name :string not null # remaining_pieces :integer # start_time :datetime not null +# submitted :boolean # created_at :datetime not null # updated_at :datetime not null # contest_id :integer not null diff --git a/spec/models/offline_spec.rb b/spec/models/offline_spec.rb index 63d7055..f164a5e 100644 --- a/spec/models/offline_spec.rb +++ b/spec/models/offline_spec.rb @@ -9,6 +9,7 @@ # name :string not null # remaining_pieces :integer # start_time :datetime not null +# submitted :boolean # created_at :datetime not null # updated_at :datetime not null # contest_id :integer not null