-
-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add training constructor and drop score stats from trainings #153
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ class GamesController < ApplicationController | |
| before_action :set_game, only: %i[show edit update destroy toggle_urgent_player_search accept_coach_invitation decline_coach_invitation] | ||
| before_action :authorize_manage_game!, only: %i[edit update destroy toggle_urgent_player_search] | ||
| before_action :prepare_coaches, only: %i[new create edit update] | ||
| before_action :prepare_training_blocks, only: %i[new create edit update] | ||
| skip_before_action :authenticate_user!, only: %i[index show] | ||
|
|
||
| helper_method :display_date, :display_time, :game_badges | ||
|
|
@@ -76,6 +77,7 @@ def create | |
|
|
||
| if @game.save | ||
| @game.ensure_prebookings_for_next_weeks if @game.prebooking_enabled? | ||
| apply_training_plan | ||
| coaches_awaiting_invitation.each { |coach| deliver_coach_invitation(coach) } | ||
| redirect_to @game, notice: "Game was successfully created." | ||
| else | ||
|
|
@@ -91,6 +93,7 @@ def update | |
| gp = sanitized_game_params | ||
| if @game.update(gp) | ||
| @game.ensure_prebookings_for_next_weeks if @game.prebooking_enabled? | ||
| apply_training_plan | ||
| coaches_awaiting_invitation.each { |coach| deliver_coach_invitation(coach) } | ||
| # The web form saves every field at once, so one message covers the whole edit. | ||
| GameChangeNotifier.notify(game: @game, actor: current_user, changes: @game.saved_changes) | ||
|
|
@@ -153,6 +156,45 @@ def prepare_coaches | |
| @coaches = User.not_merged.where(coach: true).order(:name) | ||
| end | ||
|
|
||
| # В форме показываем свою библиотеку и библиотеки тренеров этой тренировки: | ||
| # чужие блоки организатору ни к чему. | ||
| def prepare_training_blocks | ||
| owner_ids = ([ current_user&.id ] + Array(@game&.assigned_coach_ids)).compact.uniq | ||
| @training_blocks = TrainingBlock.where(user_id: owner_ids).includes(:user).ordered | ||
| end | ||
|
|
||
| def apply_training_plan | ||
| # План приходит только из формы игры, поэтому пустой запрос его не стирает. | ||
| return unless params[:game].respond_to?(:key?) && params[:game].key?(:training_block_ids) | ||
| return unless @game.training? | ||
|
|
||
| plan = training_plan_params | ||
| ids = plan[:ids] + create_training_blocks(plan[:new_blocks]) | ||
| # Блок из чужой библиотеки в план не попадает, даже если его id прислали в форме. | ||
| allowed_ids = TrainingBlock.where(id: ids, user_id: training_plan_owner_ids).pluck(:id) | ||
|
|
||
| @game.replace_training_plan!(ids.uniq.select { |id| allowed_ids.include?(id) }) | ||
| end | ||
|
|
||
| def training_plan_params | ||
| raw = params.fetch(:game, ActionController::Parameters.new) | ||
| submitted = raw[:new_training_blocks] | ||
| submitted = submitted.values if submitted.respond_to?(:values) | ||
|
|
||
| { | ||
| ids: Array(raw[:training_block_ids]).map(&:to_i), | ||
| new_blocks: Array(submitted).filter_map { |block| block.permit(:title, :description, :duration_minutes) if block.respond_to?(:permit) } | ||
| } | ||
| end | ||
|
|
||
| def create_training_blocks(new_blocks) | ||
| new_blocks.filter_map { |attrs| TrainingBlock.upsert_for(current_user, attrs)&.id } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an inline block fails validation—for example, a duration above 600, a title over 100 characters, or a description over 500 characters— Useful? React with 👍 / 👎. |
||
| end | ||
|
|
||
| def training_plan_owner_ids | ||
| ([ current_user.id ] + @game.assigned_coach_ids).uniq | ||
| end | ||
|
|
||
| # Приглашение уходит на смену слота, а не набора тренеров: если тренеров | ||
| # поменять местами, модель сбросит оба статуса в pending, и по одному только | ||
| # набору id никто бы приглашения не получил. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -66,7 +66,8 @@ def create_for_game | |
| saved_any = true | ||
| end | ||
|
|
||
| matches_input = matches_params | ||
| # На тренировке счёт не ведут, поэтому матчи из формы туда не попадают. | ||
| matches_input = game.training? ? [] : matches_params | ||
|
Comment on lines
+69
to
+70
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If an ordinary game already has saved Useful? React with 👍 / 👎. |
||
| unbalanced_matches = 0 | ||
| matches_input.each do |m| | ||
| team_a_ids = sanitize_team_ids(m[:team_a_user_ids], game) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| class TrainingBlocksController < ApplicationController | ||
| before_action :set_training_block, only: %i[update destroy] | ||
|
|
||
| def index | ||
| @training_block = TrainingBlock.new | ||
| @training_blocks = current_user.training_blocks.ordered | ||
| end | ||
|
|
||
| def create | ||
| @training_block = current_user.training_blocks.new(training_block_params) | ||
|
|
||
| if @training_block.save | ||
| redirect_to training_blocks_path, notice: t("training_blocks.created") | ||
| else | ||
| @training_blocks = current_user.training_blocks.ordered | ||
| render :index, status: :unprocessable_entity | ||
| end | ||
| end | ||
|
|
||
| def update | ||
| if @training_block.update(training_block_params) | ||
| redirect_to training_blocks_path, notice: t("training_blocks.updated") | ||
| else | ||
| redirect_to training_blocks_path, alert: @training_block.errors.full_messages.to_sentence | ||
| end | ||
| end | ||
|
|
||
| def destroy | ||
| @training_block.destroy | ||
| redirect_to training_blocks_path, notice: t("training_blocks.destroyed") | ||
| end | ||
|
|
||
| private | ||
|
|
||
| # Правку и удаление пускаем только по своей библиотеке. | ||
| def set_training_block | ||
| @training_block = current_user.training_blocks.find(params[:id]) | ||
| end | ||
|
|
||
| def training_block_params | ||
| params.require(:training_block).permit(:title, :description, :duration_minutes) | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import { Controller } from "@hotwired/stimulus" | ||
|
|
||
| // Конструктор тренировки: блоки из библиотеки отмечаются галочками, а новые | ||
| // дозаписываются прямо здесь и попадают в библиотеку вместе с сохранением игры. | ||
| export default class extends Controller { | ||
| static targets = ["container", "template"] | ||
| static values = { nextIndex: Number } | ||
|
|
||
| add(event) { | ||
| event.preventDefault() | ||
| const html = this.templateTarget.innerHTML.replaceAll("__INDEX__", this.nextIndexValue) | ||
| this.containerTarget.insertAdjacentHTML("beforeend", html) | ||
| this.nextIndexValue++ | ||
| } | ||
|
|
||
| remove(event) { | ||
| event.preventDefault() | ||
| const row = event.target.closest("[data-training-plan-row]") | ||
| if (row) row.remove() | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| class GameTrainingBlock < ApplicationRecord | ||
| belongs_to :game | ||
| belongs_to :training_block | ||
|
|
||
| validates :training_block_id, uniqueness: { scope: :game_id } | ||
|
|
||
| scope :ordered, -> { order(:position, :id) } | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| class TrainingBlock < ApplicationRecord | ||
| # Блок живёт в библиотеке тренера: заполнил один раз — дальше только выбираешь. | ||
| belongs_to :user | ||
| has_many :game_training_blocks, dependent: :destroy | ||
| has_many :games, through: :game_training_blocks | ||
|
|
||
| MAX_DURATION_MINUTES = 600 | ||
|
|
||
| before_validation :normalize_title | ||
|
|
||
| validates :title, presence: true, length: { maximum: 100 } | ||
| validates :description, length: { maximum: 500 }, allow_blank: true | ||
| validates :duration_minutes, | ||
| numericality: { only_integer: true, greater_than: 0, less_than_or_equal_to: MAX_DURATION_MINUTES }, | ||
| allow_nil: true | ||
| validate :title_is_free_in_library | ||
|
|
||
| scope :ordered, -> { order(:title) } | ||
|
|
||
| # Повторное добавление блока с тем же названием должно попадать в уже | ||
| # существующий, иначе уникальный индекс уронил бы сохранение игры. | ||
| def self.upsert_for(user, attributes) | ||
| title = attributes[:title].to_s.strip | ||
| return nil if title.blank? | ||
|
|
||
| block = named(user, title) || new(user: user, title: title) | ||
| block.description = attributes[:description].to_s.strip.presence | ||
| block.duration_minutes = attributes[:duration_minutes].presence | ||
| block.save ? block : nil | ||
| end | ||
|
|
||
| # SQLite не умеет приводить кириллицу к нижнему регистру, поэтому названия | ||
| # сравниваем в Ruby: библиотека одного тренера всё равно небольшая. | ||
| def self.named(user, title, except: nil) | ||
| where(user: user).where.not(id: except).detect { |block| block.title.casecmp?(title.to_s.strip) } | ||
| end | ||
|
|
||
| def label | ||
| return title if duration_minutes.blank? | ||
|
|
||
| "#{title} · #{duration_minutes} #{I18n.t("training_blocks.minutes_short")}" | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def normalize_title | ||
| self.title = title.to_s.strip | ||
| end | ||
|
|
||
| def title_is_free_in_library | ||
| return if title.blank? | ||
|
|
||
| errors.add(:title, :taken) if self.class.named(user, title, except: id).present? | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,6 +35,7 @@ | |
| <% end %> | ||
| </div> | ||
| <p class="mt-1 text-xs text-gray-500 dark:text-slate-400"><%= t("games.form.kind_hint") %></p> | ||
| <p class="mt-1 text-xs text-gray-500 dark:text-slate-400"><%= t("games.form.kind_stats_hint") %></p> | ||
|
|
||
| <div class="mt-3" data-game-kind-target="trainingFields"> | ||
| <label class="inline-flex items-center gap-2"> | ||
|
|
@@ -59,6 +60,67 @@ | |
| <p class="mt-1 text-xs text-gray-500 dark:text-slate-400"><%= t("games.form.second_coach_hint") %></p> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="mt-4 rounded-md border border-gray-200 p-3 dark:border-white/10" | ||
| data-controller="training-plan" | ||
| data-training-plan-next-index-value="0"> | ||
| <span class="block text-sm font-medium text-gray-700 dark:text-slate-300"><%= t("games.form.training_plan") %></span> | ||
| <p class="mt-1 text-xs text-gray-500 dark:text-slate-400"><%= t("games.form.training_plan_hint") %></p> | ||
|
|
||
| <%# Пустое значение гарантирует, что снятые галочки долетят до сервера как пустой план. %> | ||
| <%= hidden_field_tag "game[training_block_ids][]", "" %> | ||
|
|
||
| <% if @training_blocks.any? %> | ||
| <%# После неудачного сохранения отметки берём из формы, иначе из плана игры. %> | ||
| <% submitted_block_ids = params.dig(:game, :training_block_ids) %> | ||
| <% selected_block_ids = submitted_block_ids ? Array(submitted_block_ids).map(&:to_i) : (game.persisted? ? game.training_block_ids : []) %> | ||
| <div class="mt-3 space-y-2"> | ||
| <% @training_blocks.each do |block| %> | ||
| <label class="flex cursor-pointer items-start gap-2 rounded-md border border-gray-300 px-3 py-2 hover:bg-gray-50 dark:border-white/15 dark:hover:bg-white/5"> | ||
| <%= check_box_tag "game[training_block_ids][]", block.id, selected_block_ids.include?(block.id), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For multiple existing library blocks, checkbox values are submitted in this DOM order, not in the order the user clicks them, while Useful? React with 👍 / 👎. |
||
| id: "game_training_block_#{block.id}", class: "mt-1 h-4 w-4 text-indigo-600" %> | ||
| <span> | ||
| <span class="block text-sm text-gray-700 dark:text-slate-300"><%= block.label %></span> | ||
| <% if block.description.present? %> | ||
| <span class="block text-xs text-gray-500 dark:text-slate-400"><%= block.description %></span> | ||
| <% end %> | ||
| </span> | ||
| </label> | ||
| <% end %> | ||
| </div> | ||
| <% else %> | ||
| <p class="mt-3 text-xs text-gray-500 dark:text-slate-400"><%= t("games.form.training_plan_empty") %></p> | ||
| <% end %> | ||
|
|
||
| <div class="mt-3 space-y-3" data-training-plan-target="container"></div> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When game validation fails after the user has added inline blocks, Useful? React with 👍 / 👎. |
||
|
|
||
| <button type="button" | ||
| data-action="click->training-plan#add" | ||
| class="mt-3 inline-flex items-center gap-1 rounded-md border border-indigo-300 px-3 py-2 text-sm text-indigo-700 hover:bg-indigo-50 dark:border-white/15 dark:text-slate-100 dark:hover:bg-slate-700/60"> | ||
| + <%= t("games.form.training_plan_add") %> | ||
| </button> | ||
|
|
||
| <template data-training-plan-target="template"> | ||
| <div class="rounded-md border border-dashed border-gray-300 p-3 dark:border-white/15" data-training-plan-row> | ||
| <div class="grid grid-cols-1 gap-2 sm:grid-cols-3"> | ||
| <%= text_field_tag "game[new_training_blocks][__INDEX__][title]", nil, | ||
| placeholder: t("games.form.training_plan_title"), | ||
| class: "rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-white/15 dark:bg-slate-700 dark:text-slate-100" %> | ||
| <%= number_field_tag "game[new_training_blocks][__INDEX__][duration_minutes]", nil, min: 1, | ||
| placeholder: t("games.form.training_plan_duration"), | ||
| class: "rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-white/15 dark:bg-slate-700 dark:text-slate-100" %> | ||
| <%= text_field_tag "game[new_training_blocks][__INDEX__][description]", nil, | ||
| placeholder: t("games.form.training_plan_description"), | ||
| class: "rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-white/15 dark:bg-slate-700 dark:text-slate-100" %> | ||
| </div> | ||
| <button type="button" | ||
| data-action="click->training-plan#remove" | ||
| class="mt-2 text-xs text-red-600 hover:underline dark:text-red-300"> | ||
| <%= t("games.form.training_plan_remove") %> | ||
| </button> | ||
| </div> | ||
| </template> | ||
| </div> | ||
| </div> | ||
| </div> | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On both
newandcreate, this before-action runs before the action initializes@game, so@gameis nil andowner_idscontains only the current user. Selecting a coach on the new-training form therefore never exposes that coach's library; those blocks become available only after saving and reopening the game for editing. Build the game from submitted/default attributes before preparing this collection, or refresh the collection when the coach selection changes.Useful? React with 👍 / 👎.