1. 概要
サッカーの試合を「勝敗・得点・失点・理由・相手フォーメーション・自チームのスタイル」など細かく記録し、集計画面で勝率や平均得失点、最多の得点/失点理由を自動で算出できるアプリを Rails7 で作りました。
ログインユーザーごとに記録が保存され、他の人のデータは見られないシンプルな構成です。
機能一覧
- ユーザー認証(Devise)
- 試合記録の入力フォーム
- 記録一覧(詳細ページへのリンク付き)
- 詳細ページ(編集・削除ボタン付き)
- 集計ページ(勝率、平均得失点、最多理由を表示)
2. プロジェクト作成と初期設定
コマンドプロンプト
rails new soccer_log
cd soccer_log
# Devise を追加
bundle add devise
# Devise インストール & Userモデル生成
bin/rails generate devise:install
bin/rails generate devise User
bin/rails db:migrate
3. モデルとDB設計
コマンドプロンプト
rails g model Match user:reference result:integer opponent_formation:string team_style:integer notes:text gf_counter:integer gf_cross:integer gf_one_two:integer gf_long_shot:integer gf_dribble:integer gf_build_up:integer gf_accident:integer gf_other:integer ga_counter:integer ga_cross:integer ga_one_two:integer ga_long_shot:integer ga_dribble:integer ga_build_up:integer ga_accident:integer ga_other:integer
マイグレーションを修正(デフォルト0を設定):
db/migrate/〇〇_create_matches.rb
class CreateMatches < ActiveRecord::Migration[7.2]
def change
create_table :matches do |t|
t.references :user, null: false, foreign_key: true
t.integer :result, null: false, default: 0
t.string :opponent_formation
t.integer :team_style
t.text :notes
%i[
gf_counter gf_cross gf_one_two gf_long_shot gf_dribble gf_build_up gf_accident gf_other
ga_counter ga_cross ga_one_two ga_long_shot ga_dribble ga_build_up ga_accident ga_other
].each do |col|
t.integer col, null: false, default: 0
end
t.timestamps
end
end
end
コマンドプロンプト
rails db:migrate
4. モデルの定義
app/models/match.rb
class Match < ApplicationRecord
belongs_to :user
enum :result, { win: 0, draw: 1, loss: 2 }, prefix: :result
enum :team_style, {
short_counter: 0, long_counter: 1,
possession: 2, long_ball: 3, side_attack: 4
}, prefix: :style, allow_nil: true
GF_REASONS = {
gf_counter: "カウンター", gf_cross: "クロス", gf_one_two: "ワンツー",
gf_long_shot: "ミドルシュート", gf_dribble: "ドリブル",
gf_build_up: "パス回し", gf_accident: "事故", gf_other: "その他"
}
GA_REASONS = {
ga_counter: "カウンター", ga_cross: "クロス", ga_one_two: "ワンツー",
ga_long_shot: "ミドルシュート", ga_dribble: "ドリブル",
ga_build_up: "パス回し", ga_accident: "事故", ga_other: "その他"
}
with_options inclusion: { in: 0..4 } do
%i[
gf_counter gf_cross gf_one_two gf_long_shot gf_dribble gf_build_up gf_accident gf_other
ga_counter ga_cross ga_one_two ga_long_shot ga_dribble ga_build_up ga_accident ga_other
].each { |col| validates col }
end
end
5. ルーティング
config/routes.rb
Rails.application.routes.draw do
devise_for :users
authenticated :user do
root to: "users#show", as: :authenticated_root
end
unauthenticated :user do
devise_scope :user do
root to: "devise/sessions#new", as: :unauthenticated_root
end
end
get "mypage", to: "users#show"
resources :matches
get "stats", to: "stats#index", as: :stats
end
6. コントローラー
app/controllers/users_controller.rb
class UsersController < ApplicationController
before_action :authenticate_user!
def show; end
end
app/controllers/matches_controller.rb
class MatchesController < ApplicationController
before_action :authenticate_user!
before_action :set_match, only: [:show, :edit, :update, :destroy]
def index
@matches = current_user.matches.order(created_at: :desc)
end
def new
@match = current_user.matches.new
end
def create
@match = current_user.matches.new(match_params)
if @match.save
redirect_to matches_path, notice: "記録しました"
else
render :new, status: :unprocessable_entity
end
end
def show; end
def edit; end
def update
if @match.update(match_params)
redirect_to @match, notice: "更新しました"
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@match.destroy
redirect_to matches_path, notice: "削除しました"
end
private
def set_match
@match = current_user.matches.find(params[:id])
end
def match_params
params.require(:match).permit(
:result, :opponent_formation, :team_style, :notes,
:gf_counter, :gf_cross, :gf_one_two, :gf_long_shot,
:gf_dribble, :gf_build_up, :gf_accident, :gf_other,
:ga_counter, :ga_cross, :ga_one_two, :ga_long_shot,
:ga_dribble, :ga_build_up, :ga_accident, :ga_other
)
end
end
app/controllers/stats_controller.rb
class StatsController < ApplicationController
before_action :authenticate_user!
def index
@formation = params[:formation].presence
@style = params[:style].presence
rel = current_user.matches
rel = rel.by_formation(@formation) if @formation
rel = rel.by_team_style(@style) if @style
@total = rel.count
if @total.zero?
@win_pct = @draw_pct = @loss_pct = 0.0
@avg_gf = @avg_ga = 0.0
@top_ga_reason = @top_ga_pct = @top_gf_reason = @top_gf_pct = "-"
return
end
# 勝敗%
@win_pct = (rel.result_win.count * 100.0 / @total).round(1)
@draw_pct = (rel.result_draw.count * 100.0 / @total).round(1)
@loss_pct = (rel.result_loss.count * 100.0 / @total).round(1)
# 平均得点数・平均失点数
gf_expr = "gf_counter + gf_cross + gf_one_two + gf_long_shot + gf_dribble + gf_build_up + gf_accident + gf_other"
ga_expr = "ga_counter + ga_cross + ga_one_two + ga_long_shot + ga_dribble + ga_build_up + ga_accident + ga_other"
@avg_gf = rel.average(Arel.sql(gf_expr)).to_f.round(2)
@avg_ga = rel.average(Arel.sql(ga_expr)).to_f.round(2)
# 最頻理由と%
ga_sums = {
ga_counter: rel.sum(:ga_counter), ga_cross: rel.sum(:ga_cross), ga_one_two: rel.sum(:ga_one_two),
ga_long_shot: rel.sum(:ga_long_shot), ga_dribble: rel.sum(:ga_dribble), ga_build_up: rel.sum(:ga_build_up),
ga_accident: rel.sum(:ga_accident), ga_other: rel.sum(:ga_other)
}
gf_sums = {
gf_counter: rel.sum(:gf_counter), gf_cross: rel.sum(:gf_cross), gf_one_two: rel.sum(:gf_one_two),
gf_long_shot: rel.sum(:gf_long_shot), gf_dribble: rel.sum(:gf_dribble), gf_build_up: rel.sum(:gf_build_up),
gf_accident: rel.sum(:gf_accident), gf_other: rel.sum(:gf_other)
}
total_ga = ga_sums.values.sum
total_gf = gf_sums.values.sum
if total_ga.positive?
key_ga, max_ga = ga_sums.max_by { |_, v| v }
@top_ga_reason = Match::GA_REASONS[key_ga]
@top_ga_pct = ((max_ga * 100.0) / total_ga).round(1)
else
@top_ga_reason = "-"
@top_ga_pct = 0.0
end
if total_gf.positive?
key_gf, max_gf = gf_sums.max_by { |_, v| v }
@top_gf_reason = Match::GF_REASONS[key_gf]
@top_gf_pct = ((max_gf * 100.0) / total_gf).round(1)
else
@top_gf_reason = "-"
@top_gf_pct = 0.0
end
end
end
7. ビュー
app/views/users/show.html.erb
<h1>マイページ</h1>
<p><%= current_user.email %> さん、こんにちは。</p>
<ul>
<li><%= link_to "新規記録(入力)", new_match_path %></li>
<li><%= link_to "記録一覧", matches_path %></li>
<li><%= link_to "分析(結果画面)", stats_path %></li>
</ul>
<p><%= link_to "ログアウト", destroy_user_session_path, data: { turbo_method: :delete } %></p>
app/views/matches/index.html.erb
<h1>記録一覧</h1>
<p><%= link_to "新規入力", new_match_path %> | <%= link_to "結果画面(集計)", stats_path %></p>
<table border="1" cellpadding="6">
<thead>
<tr>
<th>日時</th><th>勝敗</th><th>得点</th><th>失点</th><th>フォメ</th><th>スタイル</th><th>メモ</th><th>詳細</th>
</tr>
</thead>
<tbody>
<% @matches.each do |m| %>
<tr>
<td><%= l m.created_at, format: :short %></td>
<td><%= { "win"=>"勝ち", "draw"=>"引き分け", "loss"=>"負け" }[m.result] %></td>
<td><%= m.goals_for %></td>
<td><%= m.goals_against %></td>
<td><%= m.opponent_formation.presence || "-" %></td>
<td><%= m.team_style ? {
"short_counter"=>"ショートカウンター","long_counter"=>"ロングカウンター",
"possession"=>"ポゼッション","long_ball"=>"ロングボール","side_attack"=>"サイドアタック"
}[m.team_style] : "-" %></td>
<td><%= m.notes %></td>
<td><%= link_to "詳細", match_path(m) %></td>
</tr>
<% end %>
</tbody>
</table>
app/views/matches/show.html.erb
<h1>試合詳細</h1>
<p>日時:<%= l @match.created_at, format: :short %></p>
<p>結果:<%= { "win"=>"勝ち", "draw"=>"引き分け", "loss"=>"負け" }[@match.result] %></p>
<p>相手フォメ:<%= @match.opponent_formation.presence || "-" %></p>
<p>スタイル:<%= @match.team_style ? {
"short_counter"=>"ショートカウンター","long_counter"=>"ロングカウンター",
"possession"=>"ポゼッション","long_ball"=>"ロングボール","side_attack"=>"サイドアタック"
}[@match.team_style] : "-" %></p>
<h3>得点内訳</h3>
<ul>
<% Match::GF_REASONS.each do |k, label| %>
<li><%= label %>:<%= @match.send(k) %></li>
<% end %>
</ul>
<h3>失点内訳</h3>
<ul>
<% Match::GA_REASONS.each do |k, label| %>
<li><%= label %>:<%= @match.send(k) %></li>
<% end %>
</ul>
<p>メモ:<%= simple_format(@match.notes) %></p>
<hr>
<p>
<%= link_to "編集", edit_match_path(@match) %>
|
<%# button_to は method: :delete を内包するので Turbo/Rails7 で確実に動きます %>
<%= button_to "削除", match_path(@match),
method: :delete,
form: { data: { turbo_confirm: "本当に削除しますか?" } } %>
</p>
<p><%= link_to "一覧へ戻る", matches_path %></p>
app/views/matches/new.html.erb
<h1>試合ログ入力</h1>
<%= render "form" %>
<p><%= link_to "記録一覧", matches_path %> | <%= link_to "結果画面(集計)", stats_path %></p>
app/views/matches/edit.html.erb
<h1>試合ログを編集</h1>
<%= render "form" %>
<p><%= link_to "詳細へ戻る", @match %></p>
app/views/matches/_form.html.erb
<!-- app/views/matches/_form.html.erb -->
<%= form_with model: @match, local: true do |f| %>
<fieldset>
<legend>勝敗</legend>
<%= f.collection_radio_buttons :result, Match.results.keys, :to_s, ->(k){ { "win"=>"勝ち", "draw"=>"引き分け", "loss"=>"負け" }[k] } %>
</fieldset>
<hr>
<h3>得点(0〜4)</h3>
<% [:gf_counter,:gf_cross,:gf_one_two,:gf_long_shot,:gf_dribble,:gf_build_up,:gf_accident,:gf_other].each do |field| %>
<div><%= f.label field, Match::GF_REASONS[field] %> <%= f.number_field field, in: 0..4, step: 1 %></div>
<% end %>
<h3>失点(0〜4)</h3>
<% [:ga_counter,:ga_cross,:ga_one_two,:ga_long_shot,:ga_dribble,:ga_build_up,:ga_accident,:ga_other].each do |field| %>
<div><%= f.label field, Match::GA_REASONS[field] %> <%= f.number_field field, in: 0..4, step: 1 %></div>
<% end %>
<hr>
<div>
<%= f.label :opponent_formation, "相手フォーメーション(例: 4123)" %><br>
<%= f.text_field :opponent_formation, placeholder: "4123" %>
</div>
<div>
<%= f.label :team_style, "チームスタイル" %><br>
<%= f.select :team_style,
Match.team_styles.keys.map { |k| [ { "short_counter"=>"ショートカウンター","long_counter"=>"ロングカウンター",
"possession"=>"ポゼッション","long_ball"=>"ロングボール","side_attack"=>"サイドアタック" }[k], k] },
include_blank: true %>
</div>
<div>
<%= f.label :notes, "その他メモ" %><br>
<%= f.text_area :notes, rows: 3, placeholder: "例:サイドバック攻撃的" %>
</div>
<%= f.submit %>
<% end %>
8. 実行
コマンドプロンプト
rails s