1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Rails】サッカー試合ログ管理アプリ

Posted at

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
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?