[ 個人開発 ] simple_calendarを使用してカレンダー機能を実装する


<h3>はじめに</h3>
こんにちは！<br>
個人開発アプリ「甲子園NOW!」作成時の実装内容の備忘録として記事をまとめています。
全体の内容の一覧は以下にまとめています。

https://qiita.com/CarBoss1996/items/63c14260fc7fe65da78e

今回はその機能のうちの1つの simple_calendar についてまとめていきたいと思います。

⚠︎表示内容と保持しているデータがかなり多いので、デプロイ先のDBのCPUとストレージが足りなくてカレンダーが表示されない(502エラー)という事態になってしまいました。。。
なので就活中の現在のみ課金してなんとか表示できている状態です(汗)
renderのスターター[ 256MB ラム、100メートル CPU、1GB ストレージ ]にしたら表示されるようになりました(汗)
もし同じような実装をされる方がいれば、容量の多いデプロイ先とDBを探してから実装していったほうがいいかと思います。
そしてこの試合情報やイベントのデータはseeds.rbに手入力しています。

<h3>開発環境</h3>
・ Ruby on Rails  7.1.3.4<br>
・ Ruby  3.2.3
・ render<br>
・ render PostgreSQL<br>

<h3>完成物</h3>
<a href="https://gyazo.com/c91ca9092995f0bf58d80d114cc8c5a5"><img src="https://i.gyazo.com/c91ca9092995f0bf58d80d114cc8c5a5.png" alt="Image from Gyazo" width="1920"/></a>
<a href="https://gyazo.com/d5532025bf6918b772a863b7e0e2444d"><img src="https://i.gyazo.com/d5532025bf6918b772a863b7e0e2444d.png" alt="Image from Gyazo" width="376"/></a>

<h3>手順</h3>
1. gem simple_calender をインストールする<br>
2. カレンダーのテンプレートを作成する<br>
3. カレンダーの一覧画面を作成する<br>
4. カレンダーの日毎の画面を作成する<br>
5. 選択した月の一覧画面を作成する<br>
6. リスト形式のカレンダーを作成する<br>

<h3>1. gem simple_calender をインストールする</h3>
Gemfile に simple_calendar を追記します。

```Gemfile.
gem 'simple_calendar'
```
そして bundle install してサーバーを再起動します。
今回はカレンダーの月表示の切り替えで turbo も使用するので、インストールがまだの方はしておいてください。
turbo 使わないって方は無しでも大丈夫です！


<h3>2. カレンダーのテンプレートを作成する</h3>

simple_calendar の Github の README を見ていただけると、かなり詳しく解説してくれています。

https://github.com/excid3/simple_calendar

しかし、私はデフォルトのスタイルシートをインポートしておらず、こちらの記事の方のgithub から参考にさせていただきました。

https://qiita.com/trafford_777/items/8c9b00e523625311174a


今更ながら、インポートしたらもう少し楽だったかな。。。笑
そしてカレンダー機能の土台となるテンプレート作成から始めます。
今回は１ヶ月のカレンダーを１ページで表示したかったので、そのように作成します。
app/views/simple_calendar/_month_calendar.html.erb ファイルに記述していきます。

```app/views/simple_calendar/_month_calendar.html.erb
<div class='simple-calendar'>
  <div class='text-center'>

  <!-- 何月かを表示する -->
    <% if @month %>
      <h2><%= @month.strftime("%m月") %></h2>
    <% end %>
  </div>
  <div class='d-flex justify-content-end'>

  <!-- 3月から10月までのリンクを生成 -->
    <% (3..10).each do |month| %>
      <%= link_to month, show_month_matches_path(month: "#{Date.today.year}-#{month.to_s.rjust(2, '0')}"), data: { turbo_action: 'replace', turbo_frame: 'game-view' }, class: 'mx-2' %>
    <% end %>
  </div>

  <!-- カレンダーを表示するためのテーブル -->
  <table class='table table-striped'>
    <thead>
      <tr>

  <!-- 週の各日付を表示するためのヘッダーを生成 -->
        <% date_range.slice(0, 7).each do |day| %>
          <th><%= t('date.abbr_day_names')[day.wday] %></th>
        <% end %>
      </tr>
    </thead>

    <tbody>
    <!-- 日付を7日ごとにスライスして各週を表し、それぞれの週に対して新しいテーブル行(<tr>)を生成 -->
      <% date_range.each_slice(7) do |week| %>
        <%= content_tag :tr, class: calendar.tr_classes_for(week) do %>

    <!-- 各週の中で1日ずつ処理を行い、それぞれの日に対して新しいテーブルデータ（<td>）を生成 -->
          <% week.each do |day| %>
            <%= content_tag :td, class: calendar.td_classes_for(day) do %>
              <% passed_block.call day, sorted_events.fetch(day, []) %>
            <% end %>
          <% end %>
        <% end %>
      <% end %>
    </tbody>
  </table>
</div>
```
scssで見た目を整えていきます。

```scss.
li.info.result {
  list-style-type: none;
}

td.day {
  height: 117px; /* 高さを均等に */
  width: 14.28%; /* 100% / 7 days = 14.28% 横幅も均等に*/
}
```

そして週の始まりを日曜日にしたいので、カレンダーを表示するページのコントローラに記述します。
```controller.rb
  private

  def set_beginning_of_week  # 週の始まりを日曜日に設定
    Date.beginning_of_week = :sunday
  end
end
```
これでカレンダーの大枠の部分の設定が完了しました。
次にカレンダーの一覧画面を作成していきます。


<h3>3. カレンダーの一覧画面を作成する</h3>
今回は対戦カレンダーを作成したいので、matches フォルダにファイルを作成していきます。

ルーティングを記述します。
先に今回使用するページ全てのルーティングを記述します。
```routes.rb
  resources :matches do
    collection do
      get :match_list
      get :match_calendar
      get 'show_month/:month', to: 'matches#show_month', as: :show_month
    end
  end
```
turboを使用したいので、collection do で matches リソース全体に対するルーティングを定義して、idがなくてもページが表示できるようにします。
月のリンクから任意の月のカレンダーを表示させたいので、/matches/show_month/:month という URL で matches#show_month アクションを呼び出します。
:monthは動的なセグメントで、URLの一部として任意の値を受け取ることができます。
as: :show_monthはこのルーティングにshow_monthという名前を付けています。
これにより、ビューやコントローラーからshow_month_path(month)のような形でURLを生成することができます。
<br>
モデルの match.rb を作成していきます。
```match.rb
class Match < ApplicationRecord
  belongs_to :event, optional: true
  has_many :user_matches
  has_many :users, through: :user_matches
  enum opponent: { 巨人: 0, 広島: 1, 中日: 2, ヤクルト: 3, DeNA: 4, オリックス: 5, ソフトバンク: 6, 日本ハム: 7, 西武: 8, ロッテ: 9, 楽天: 10 }
  STADIUM_NAMES = {
    "tokyo" => "東京ドーム",
    "mazda" => "マツダスタジアム",
    "nagoya" => "バンテリンドーム",
    "jingu" => "神宮球場",
    "yokohama" => "横浜スタジアム",
    "kyosera" => "京セラドーム",
    "hukuoka" => "PayPayドーム",
    "sapporo" => "エスコンフィールド",
    "seibu" => "ベルーナドーム",
    "rotte" => "ZOZOマリンスタジアム",
    "rakuten" => "楽天モバイルパーク宮城"
  }
  enum result: { 勝ち: 0, 負け: 1, 引き分け: 2, 雨天中止: 3 }
end
```
イベントは毎日ないので、optional: true を記述して、nil でもエラーにならないようにします。
ユーザーの試合履歴も保持したいので、 user_matchモデルを作成して１対多で関連付け、ユーザーモデルとの中間テーブルにします。
対戦相手は opponent で enum で記述していきます。
交流戦もあるので、１１球団全て登録します。
日本語化のためにスタジアムの名前を表すハッシュを定義します。
試合結果(result)も enum で記述します。
<br>
次にコントローラ（matches_controller.rb）を作成します。
```matches_controller.rb
require 'httparty'
require 'json'
require 'uri'

class MatchesController < ApplicationController
  before_action :set_beginning_of_week
  
  def index
    @date = params[:date] ? Time.zone.parse(params[:date]) : Time.zone.today # 指定された日付、または現在の日付を取得
    @month = @date.beginning_of_month # 指定された日付、または現在の日付の月初を取得
     #この部分はデータの読み込みを少しでも少なくするようにキャッシュから読み込む記述をしてます
    @matches = Rails.cache.fetch("matches/#{@month}", expires_in: 12.hours) do
      Match.where(match_date: @month.beginning_of_month..@date.end_of_month).order(match_date: :desc)
    end
    @events = Rails.cache.fetch("events/#{@month}", expires_in: 12.hours) do
      Event.eager_load(:event_dates)
          .where('event_dates.start_date <= :end_of_month AND event_dates.end_date >= :start_of_month', start_of_month: @month.beginning_of_month, end_of_month: @month.end_of_month)
    end
    # ここまで
    if current_user # ログインしている場合ユーザーの観戦履歴と位置情報があれば表示する
      @user_matches = current_user.user_matches.where(date: @month.beginning_of_month..@month.end_of_month)
      @user_locations = current_user.user_locations.where(date: @month.beginning_of_month..@date.end_of_month)
    else  # 情報がない場合、空値を設定する
      @user_matches = []
      @user_locations = []
    end
  end
end
```
試合データとイベントデータをキャッシュで保持させるようにすると、データ通信量が少なくなるので５０２エラーが起きにくいと考えたため、このような記述になっています。
<br>
まずは一覧ページとなる index ファイルを作成していきます。
```matches/index.html.erb
<turbo-frame id="game-view">
 <div class="mt-3">
 <!-- スマホサイズでは非表示、それ以上のサイズでは表示 -->
  <div class="d-none d-sm-block">
    <%= render 'matches/match_calendar', matches: @matches, month: @month %>
  </div>
 <!-- スマホサイズでは表示、それ以上のサイズでは非表示 -->
  <div class="d-block d-sm-none">
   <%= render 'matches/match_list', matches: @matches, month: @month %>
  </div>
 </div>
</turbo-frame>
```
月の選択をした時にユーザービリティ向上のため全ページのリロードをしたくなかったので、turbo-frame を使用して部分的なページ更新を可能にしました。
PCでの閲覧の場合はカレンダー形式で、スマートフォンからの閲覧の場合はカレンダーだと見にくいのでリスト表示になるように設定しています。
リストの方は最後にまとめる予定なので、先にPCでのカレンダー表示を実装していきます。

<h3>4. カレンダーの日毎の画面を作成する</h3>
先程作成した index でレンダリングしている matches/match_calendar を作成していきます。
これも日毎とに対応する情報を表示するためのテンプレートです。

```matches/_match_calendar.html.erb
<!-- month_calendarヘルパーを使用して、指定した月のカレンダーを生成、各日付に対してブロックを行う -->
<%= month_calendar start_date: @month do |date| %>
<!-- ユーザーがその日に位置情報を持っているかどうかを確認、あればその日のクラスにattended-matchを設定 -->
  <% day_classes = @user_locations.find { |location| location.date == date } ? 'attended-match' : '' %>
  <!-- その日の日付を表示 -->
  <div class="<%= day_classes %>"><%= date.day %></div>
  <!-- その日に行われる試合をすべて選択 -->
  <% matches_on_date = @matches.select { |match| match.match_date.to_date == date } %>
  <!-- その日に行われる各試合に対して、以下のブロックを実行 -->
  <% matches_on_date.each do |match| %>
    <div class='mb-1 text-center'>
    <!-- 試合の結果が存在する場合、以下のブロックを実行 -->
      <% if match.result %>
      <!-- 試合の結果に基づいて、試合の色を設定 -->
        <% match_color = if match.result == '勝ち'
                           'rgba(255, 60, 0, 0.3)' # 透明の赤色
                         elsif match.result == '負け'
                           'rgba(0, 160, 255, 0.3)' # 透明の青色
                         else
                           'transparent' # 透明（引き分けまたは未定の場合）
                         end %>
        <div style="background-color: <%= match_color %>;">
          <div style="box-shadow: 0 0 10px <%= match_color %>;">
          <!-- ホームチームとアウェイチームのスコアを表示 -->
          <%= match.team_score %> - <%= match.away_team_score %><br>
          <!-- 対戦相手を表示 -->
          vs <%= match.opponent %>
        </div>
        <!-- 試合の結果が存在しない場合、以下のブロックを実行 -->
      <% else %>
        <div>
          vs <%= match.opponent %><br>
          <!-- 試合の開始時間を表示 -->
          <%= match.match_date.strftime('%H:%M') %><br>
    <!-- 試合が行われるスタジアムの名前を表示、もしスタジアムの名前が存在しなければ、"甲子園"を表示 -->
          <%= Match::STADIUM_NAMES[match.stadium] || "甲子園" %>
        </div>
      <% end %>
    </div>
  <% end %>

  <!-- その日にユーザーが観戦予定の試合を見つける -->
    <% user_match_on_date = @user_matches.find { |user_match| user_match.date == date } %>
    <!-- ユーザーがその日に観戦予定の試合がある場合、以下のブロックを実行 -->
  <% if user_match_on_date %>
  <!-- ユーザーが観戦予定の試合の情報を表示 -->
    <div class='user-match-show mt-1'>
      <div class='user-match-match'>
        観戦予定
      </div>
    </div>
  <% end %>

  <!-- ユーザーの位置情報が存在する場合、以下のブロックを実行 -->
  <% if @user_locations %>
  <!-- その日のユーザーの位置情報を見つける -->
    <% user_location_on_date = @user_locations.find { |location| location.date == date } %>
    <!-- ユーザーのその日の位置情報が存在する場合、以下のブロックを実行 -->
    <% if user_location_on_date %>
      <div class='user-calendar-show mt-1'>
        <div class='calendar-icon'>
        <!-- ユーザーの位置情報のアイコンを表示 -->
          <%= image_tag("icons/#{user_location_on_date.icon}", alt: user_location_on_date.icon) %>
        </div>
        <div class='calendar-seat'>
        <!-- ユーザーの位置情報のタイプに対応する座席を表示 -->
          <%= I18n.t("seats.#{user_location_on_date.location_type}") %>
        </div>
      </div>
    <% end %>
  <% end %>

  <!-- その日に行われるイベントを見つける -->
  <% event_on_date = @events.find { |event| event.event_dates.any? { |event_date| date >= event_date.start_date && date <= event_date.end_date } } %>
  <!-- その日にイベントが存在する場合、以下のブロックを実行 -->
  <% if event_on_date %>
    <div class='event-show mt-1'>
      <%= event_on_date.title %>
    </div>
  <% end %>
<% end %>
```
コメントを入れながらのコードはかなり長いので、内容をまとめると、以下のとおりです。
| | 過去の試合 | 今日含む先の試合 | 共通事項 |
|:-:|:-:|:-:|:-:|
| 試合情報 | 試合結果とスコアを表示 | 試合時間と会場を表示 | 対戦相手とイベントを表示 |
| ユーザーに関する情報 | 位置情報を登録している場合、アイコンとシート名を表示 | 観戦予定を登録している場合、観戦予定という文字を表示 |  |

ここまで見てくださった方にはよくわかると思いますが、かなり内容が多いです。
環境構築はよく考えてやらないと、というのを実感しました。


<h3>5. 選択した月の一覧画面を作成する</h3>
先程作成したカレンダーのテンプレート(simple_calendar/_month_calendar.html.erb)の「3月から10月までのリンクを生成」の部分でリンクにしていた show_month ページを作成していきます。

```matches_controller.rb
  def show_month
    begin
      @month = params[:month] ? Time.zone.strptime(params[:month], "%Y-%m") : Time.zone.today # strptimeメソッドは文字列を日付に変換
    rescue ArgumentError # strptimeメソッドが無効な日付を解析しようとした場合に発生する例外を捕捉、その場合、@monthは現在の月に設定
      @month = Time.zone.today
    end
    if current_user  # ログインしている場合ユーザーの観戦履歴と位置情報があれば表示する
      @user_locations = current_user.user_locations.where(date: @month.beginning_of_month..@month.end_of_month)
      @user_matches = current_user.user_matches.where(date: @month.beginning_of_month..@month.end_of_month)
    else  # 情報がない場合、空値を設定する
      @user_locations = []
      @user_matches = []
    end
    #この部分はデータの読み込みを少しでも少なくするようにキャッシュから読み込む記述をしてます
    @matches = Rails.cache.fetch("matches/#{@month}", expires_in: 12.hours) do
      Match.where(match_date: @month.beginning_of_month..@month.end_of_month).order(match_date: :desc)
    end
    @events = Rails.cache.fetch("events/#{@month}", expires_in: 12.hours) do
      Event.eager_load(:event_dates)
          .where('event_dates.start_date <= :end_of_month AND event_dates.end_date >= :start_of_month', start_of_month: @month.beginning_of_month, end_of_month: @month.end_of_month)
    end
    # ここまで
    render 'index'
  end
  ```

```matches/show_month.html.erb
<div class='container'>
  <div class='row justify-content-center'>
    <div class='col'>
    <h3 class='text-center my-3'><%= t('activerecord.models.match') %></h3>
      <turbo-frame id="game-view">
        <div id='game-view'>
          <!-- スマホサイズでは非表示、それ以上のサイズでは表示 -->
          <div class="d-none d-sm-block">
          <!-- month_calendarヘルパーを使用して、指定した月のカレンダーを生成、各日付に対してブロックを実行 -->
            <%= month_calendar start_date: @month do |date| %>
            <!-- 日の日付を表示 -->
              <%= date.day %>
              <!-- 各試合に対して、以下のブロックを実行 -->
              <% @matches.each do |match| %>
              <!-- 試合の日付がその日の日付と一致する場合、以下のブロックを実行 -->
                <% if match.match_date == date %>
                <!-- 試合のタイトルを表示 -->
                  <%= match.title %>
                <% end %>
              <% end %>
            <% end %>
          </div>
          <!-- スマホサイズでは表示、それ以上のサイズでは非表示 -->
          <div class="d-block d-sm-none">
 <!-- _match_list.html.erbパーシャルをレンダリング、@matchesと@monthをパーシャルに渡す -->
            <%= render 'matches/match_list', matches: @matches, month: @month %>
          </div>
        </div>
      </turbo-frame>
    </div>
  </div>
</div>
```


<h3>6. リスト形式のカレンダーを作成する</h3>

スマートフォンの場合のリスト形式での表示ページです。
先述のカレンダー要素を使用できたら良かったのですが、リストに使用するのは難しかったのでまた１から作成です。
でも表示内容は同じなので、ほとんどコピペで行けるので、作成時間はそんなに長くなかったです。

```matches/_match_list.html.erb
<%# カレンダーのリスト表示 %>
<div class='simple-calendar'>
  <div class='text-center'>
    <% if @month %>
      <h2><%= @month.strftime("%m月") %></h2>
    <% end %>
  </div>
  <div class='d-flex justify-content-end'>
    <% (3..10).each do |month| %>
      <%= link_to month, show_month_matches_path(month: "#{Date.today.year}-#{month.to_s.rjust(2, '0')}"), data: { turbo_action: 'replace', turbo_frame: 'game-view' }, class: 'mx-2' %>
    <% end %>
  </div>
</div>

<table class="table table-bordered" style="background-color: white;">
  <thead>
    <% (@month.to_date.beginning_of_month..@month.to_date.end_of_month).each do |date| %>
      <tr>
        <td colspan="4">
          <div class="d-flex justify-content-between">
            <div>
              <%= date.strftime('%m/%d(%a)') %>
            </div>
            <div class="d-flex">
              <% user_match_on_date = @user_matches.find { |user_match| user_match.date == date } %>
              <% user_location_on_date = @user_locations.find { |location| location.date == date } if @user_locations %>
              <% event_on_date = @events.find { |event| event.event_dates.any? { |event_date| date >= event_date.start_date && date <= event_date.end_date } } if @events %>
              <% if user_match_on_date %>
                <div class='user-match-show mr-2'>
                  <div class='user-match-match'>
                    _観戦予定_
                  </div>
                </div>
              <% end %>
              <% if user_location_on_date %>
                <div class='user-calendar-show'>
                  <div class='calendar-icon'>
                    <%= image_tag("icons/#{user_location_on_date.icon}", alt: user_location_on_date.icon) %>
                  </div>
                  <div class='calendar-seat'>
                    <%= I18n.t("seats.#{user_location_on_date.location_type}") %>
                  </div>
                </div>
              <% end %>
            </div>
          </div>
        </td>
      </tr>
      <% matches_on_date = @matches.select { |match| match.match_date.present? && match.match_date.to_date == date } %>
      <% if matches_on_date.empty? %>
        <tr>
          <td colspan="4">&nbsp;</td> <!-- 空白の行を追加 -->
        </tr>
      <% else %>
        <% matches_on_date.each do |match| %>
          <tr>
            <td>
              <div class='mb-1 text-center'>
                <% if match.result %>
                  <% match_color = if match.result == '勝ち'
                                    'rgba(255, 60, 0, 0.3)' # 透明の赤色
                                  elsif match.result == '負け'
                                    'rgba(0, 160, 255, 0.3)' # 透明の青色
                                  else
                                    'transparent' # 透明（引き分けまたは未定の場合）
                                  end %>
                  <div style="background-color: <%= match_color %>;">
                    <div style="box-shadow: 0 0 10px <%= match_color %>;">
                      <%= match.team_score %> - <%= match.away_team_score %><br>
                      vs <%= match.opponent %>
                    </div>
                  </div>
                <% else %>
                  <div>
                    vs <%= match.opponent %><br>
                    <%= match.match_date.strftime('%H:%M') %><br>
                    <%= Match::STADIUM_NAMES[match.stadium] || "甲子園" %>
                  </div>
                <% end %>
                <% if event_on_date %>
                  <div class='event-show'>
                    <div class='event-title'>
                      <%= event_on_date.title %>
                    </div>
                  </div>
                <% end %>
              </div>
            </td>
          </tr>
        <% end %>
      <% end %>
    <% end %>
  </thead>
</table>
```
以下全体的なscssの記述です。
```scss.
.match_calendar {
  display: flex;
  flex-direction: column;
}
.user-calendar-show {
  background-color: #FFDE59;
  display: flex;
  justify-content: flex-start; /* テキストを中央に配置 */
}
.calendar-icon img {
  width: 25px;
  height: 25px;
}
.calendar-icon, .calendar-seat {
  flex: auto;
  min-width: 0;
}
.calendar-seat {
  font-size: 0.8rem;
}
.user-match-match {
  background-color: #FF6347; /* Tomato color */
  color: #FFFFFF;
  text-align: center;
}

.event-show {
  background-color: #03b603;
  color: #FFFFFF;
  display: flex;
  justify-content: center;
  align-items: center;

  @media (min-width: 1024px) {
    font-size: 0.8em;
  }
}
```
ぱっと見で試合情報や観戦履歴を確認できるようにしたかったので情報を盛りだくさんにしてしまいましたが、色や配置でわかりやすくしているので、そんなにごちゃついて見えることはないかなと思います。

<h3>最後に</h3>
今回も長かったですが、最後までお読みいただきありがとうございます！<br>
備忘録ですが、参考になれば幸いです。<br>
初学者のため、間違った見解や解説もあると思いますが、もしよければ優しくご教示いただけるととても喜びます。

<h3>参考記事</h3>

https://github.com/excid3/simple_calendar
https://qiita.com/trafford_777/items/8c9b00e523625311174a
https://zenn.dev/goldsaya/articles/a100264193f205
https://qiita.com/kazama1209/items/3379cf2db9981f617791