Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions app/controllers/games_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Load selected coaches' blocks during creation

On both new and create, this before-action runs before the action initializes @game, so @game is nil and owner_ids contains 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 👍 / 👎.

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 }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject invalid inline blocks before saving the game

When an inline block fails validation—for example, a duration above 600, a title over 100 characters, or a description over 500 characters—upsert_for returns nil and this filter_map silently discards it. The game has already been saved, so the request redirects with a success notice while the entered block and its plan position are lost; surface these validation errors and avoid completing the game save as successful.

Useful? React with 👍 / 👎.

end

def training_plan_owner_ids
([ current_user.id ] + @game.assigned_coach_ids).uniq
end

# Приглашение уходит на смену слота, а не набора тренеров: если тренеров
# поменять местами, модель сбросит оба статуса в pending, и по одному только
# набору id никто бы приглашения не получил.
Expand Down
3 changes: 2 additions & 1 deletion app/controllers/player_statistics_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove or prohibit scores when converting to training

If an ordinary game already has saved Match rows and is later edited to kind: "training", this condition only ignores future score submissions; the existing matches remain linked to the game and continue appearing in player histories and contributing to accumulated statistics. Because the training UI now hides all match controls, users cannot see or correct that retained score from the game page, so the kind transition must either be blocked or explicitly reconcile the existing matches and statistics.

Useful? React with 👍 / 👎.

unbalanced_matches = 0
matches_input.each do |m|
team_a_ids = sanitize_team_ids(m[:team_a_user_ids], game)
Expand Down
43 changes: 43 additions & 0 deletions app/controllers/training_blocks_controller.rb
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
21 changes: 21 additions & 0 deletions app/javascript/controllers/training_plan_controller.js
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()
}
}
26 changes: 26 additions & 0 deletions app/models/game.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
class Game < ApplicationRecord
after_commit :schedule_post_game_stats_reminder, on: %i[create update]
after_commit :enqueue_urgent_player_search_notification, on: %i[create update]
after_update :drop_training_plan_from_plain_game, if: -> { saved_change_to_kind? && !training? }
after_update :remove_stale_coach_prebookings,
if: -> { saved_change_to_coach_id? || saved_change_to_second_coach_id? || saved_change_to_date? || saved_change_to_recurring? }

Expand All @@ -19,6 +20,9 @@ class Game < ApplicationRecord
has_many :featured_matches, dependent: :nullify
has_many :player_statistic_entries, dependent: :nullify
has_many :game_media, class_name: "GameMedium", dependent: :destroy
# План тренировки — блоки из библиотеки тренера в выбранном порядке.
has_many :game_training_blocks, -> { ordered }, dependent: :destroy, inverse_of: :game
has_many :training_blocks, through: :game_training_blocks

SURFACES = Court::SURFACES
KINDS = %w[game training].freeze
Expand Down Expand Up @@ -54,6 +58,23 @@ def coaches
[ coach, second_coach ].compact
end

# Порядок блоков задаёт сам список: он и есть план занятия.
def replace_training_plan!(block_ids)
block_ids = Array(block_ids).map(&:to_i).uniq.reject(&:zero?)

transaction do
game_training_blocks.where.not(training_block_id: block_ids).destroy_all
block_ids.each_with_index do |block_id, index|
entry = game_training_blocks.find_or_initialize_by(training_block_id: block_id)
entry.position = index
entry.save!
end
end

game_training_blocks.reset
training_blocks.reset
end

def assigned_coach_ids
[ coach_id, second_coach_id ].compact
end
Expand Down Expand Up @@ -523,6 +544,11 @@ def normalize_invitation_status(slot)
end
end

def drop_training_plan_from_plain_game
# У обычной игры плана занятия не бывает, поэтому он уходит вместе с типом.
game_training_blocks.destroy_all
end

def selected_coaches_are_coaches
coaches.each do |candidate|
errors.add(:coach, "must be a coach") unless candidate.coach?
Expand Down
8 changes: 8 additions & 0 deletions app/models/game_training_block.rb
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
55 changes: 55 additions & 0 deletions app/models/training_block.rb
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
2 changes: 2 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ def skill_level_display_for(sport)
has_many :coached_games, class_name: "Game", foreign_key: :coach_id, dependent: :nullify, inverse_of: :coach
has_many :second_coached_games, class_name: "Game", foreign_key: :second_coach_id, dependent: :nullify, inverse_of: :second_coach
has_many :coach_prebookings, foreign_key: :coach_id, dependent: :destroy, inverse_of: :coach
# Библиотека блоков тренировок принадлежит тренеру и уходит вместе с ним.
has_many :training_blocks, dependent: :destroy
has_many :participations
has_many :favorite_court_links, class_name: "FavoriteCourt", dependent: :destroy
has_many :court_suggestions, dependent: :destroy
Expand Down
62 changes: 62 additions & 0 deletions app/views/games/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand All @@ -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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Submit an explicit training-plan order

For multiple existing library blocks, checkbox values are submitted in this DOM order, not in the order the user clicks them, while @training_blocks is alphabetically ordered by title. replace_training_plan! then treats that submitted sequence as the plan order and the show page numbers it, so organizers cannot construct a non-alphabetical session sequence; provide ordering controls and submit explicit positions.

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>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Repopulate inline blocks after validation errors

When game validation fails after the user has added inline blocks, create renders the form again but this container is always empty and the submitted new_training_blocks values are never rendered. The user consequently loses every title, duration, and description they entered merely because an unrelated game field was invalid; rebuild these rows from params on the error render.

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>

Expand Down
11 changes: 9 additions & 2 deletions app/views/games/_stats_form.html.erb
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
<% match_groups = PlayerStatistics::MatchGroups.for_game(game) %>
<% match_groups = game.training? ? [] : PlayerStatistics::MatchGroups.for_game(game) %>
<% saved_hours = PlayerStatistics::SavedHours.for_game(game, user: current_user) %>
<%= form_with url: game_player_statistics_path(game), method: :post, data: { turbo: false }, class: "space-y-4" do %>
<div data-controller="stats-matches" data-stats-matches-next-index-value="<%= [ match_groups.size, 1 ].max %>" class="space-y-4">
<div class="space-y-4">
<div>
<label class="mb-1 block text-sm text-gray-600 dark:text-slate-400"><%= t("games.show.hours") %></label>
<%= number_field_tag "statistics[hours]", saved_hours, step: 0.1, min: 0, class: "w-32 rounded border-gray-300 text-sm dark:border-white/15 dark:bg-slate-700 dark:text-slate-100" %>
</div>
</div>

<%# У тренировки нет счёта — там считают только часы, поэтому матчи не показываем. %>
<% if game.training? %>
<p class="text-sm text-gray-500 dark:text-slate-400"><%= t("games.show.training_stats_hint") %></p>
<% else %>
<div data-controller="stats-matches" data-stats-matches-next-index-value="<%= [ match_groups.size, 1 ].max %>" class="space-y-4">
<div data-stats-matches-target="container" class="space-y-4">
<% if match_groups.any? %>
<% match_groups.each_with_index do |group, i| %>
Expand All @@ -27,6 +33,7 @@
<%= render "games/stats_match_block", game: game, index: "__INDEX__", removable: true %>
</template>
</div>
<% end %>

<% if false %>
<details class="rounded border bg-gray-50 p-3 dark:bg-slate-700/50">
Expand Down
Loading