【Rails 6】simple_calendar でシンプルなカレンダー機能を作ってみる

## 概要

「[simple_calendar](https://github.com/excid3/simple_calendar)」 という gem を使ってシンプルなカレンダー機能を Rails アプリに実装してみます。

## 完成イメージ

![タイトルなし.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/e6107b2b-86ec-8dde-17db-f9a155058a44.gif)

## 仕様

- Ruby 3
- Rails 6
- MySQL 5.7
- Bootstrap 5
- Docker

※ Bootstrap はバージョン5から JQuery が不要になり導入方法が少し変わったのでご注意ください。

## 下準備

まず最初に下準備から始めていきます。

### 各種ディレクトリ & ファイルを作成

```
$ mkdir rails-simple-calendar-app && cd rails-simple-calendar-app
$ touch Dockerfile docker-compose.yml entrypoint.sh Gemfile Gemfile.lock
```

```dockerfile:./Dockerfile
FROM ruby:3.0

RUN curl https://deb.nodesource.com/setup_14.x | bash

RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
    && echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list

RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs yarn

ENV APP_PATH /myapp

RUN mkdir $APP_PATH
WORKDIR $APP_PATH

COPY Gemfile $APP_PATH/Gemfile
COPY Gemfile.lock $APP_PATH/Gemfile.lock
RUN bundle install

COPY . $APP_PATH

COPY entrypoint.sh /usr/bin/
RUN chmod +x /usr/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
EXPOSE 3000

CMD ["rails", "server", "-b", "0.0.0.0"]
```

```yml:./docker-compose.yml
version: "3"
services:
  db:
    image: mysql:5.7
    environment:
      MYSQL_ROOT_PASSWORD: password
    volumes:
      - mysql-data:/var/lib/mysql
      - /tmp/dockerdir:/etc/mysql/conf.d/
    ports:
      - 4306:3306
  web:
    build:
      context: .
      dockerfile: Dockerfile
    command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    volumes:
      - .:/myapp
      - ./vendor/bundle:/myapp/vendor/bundle
    environment:
      TZ: Asia/Tokyo
      RAILS_ENV: development
    ports:
      - "3000:3000"
    depends_on:
      - db
volumes:
  mysql-data:
```


```sh:./entrypoint.sh
#!/bin/bash
set -e

# Remove a potentially pre-existing server.pid for Rails.
rm -f /myapp/tmp/pids/server.pid

# Then exec the container's main process (what's set as CMD in the Dockerfile).
exec "$@"
```

```ruby:./Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

gem "rails", "~> 6"
```

```ruby:./Gemfile.lock
# 空欄でOK
```

### rails new

おなじみのコマンドでアプリの雛型を作成。

```
$ docker-compose run web rails new . --force --no-deps -d mysql --skip-test
```

### database.ymlを編集

デフォルトの状態だとデータベースとの接続ができないので「database.yml」の一部を書き換えます。

```yml:./config/database.yml
default: &default
  adapter: mysql2
  encoding: utf8mb4
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: root
  password: password
  host: db

development:
  <<: *default
  database: myapp_development

test:
  <<: *default
  database: myapp_test

production:
  <<: *default
  database: <%= ENV["DATABASE_NAME"] %>
  username: <%= ENV["DATABASE_USERNAME"] %>
  password: <%= ENV["DATABASE_PASSWORD"] %>
  host: <%= ENV["DATABASE_HOST"] %>
```

### コンテナを起動 & データベースを作成

```
$ docker-compose build
$ docker-compose up -d
$ docker-compose run web bundle exec rails db:create
```

### 動作確認（localhost:3000 にアクセス）

![スクリーンショット 2022-01-03 21.41.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/6d3c7749-4e15-01c9-046e-9bb02ac964a6.png)


[localhost:3000](http://localhost:3000/) にアクセスしてウェルカムページが表示されればOKです。

### slim を導入

個人的にビューのテンプレートエンジンは erb よりも slim の方が好みなので変更します。

```rb:./Gemfile
gem 'slim-rails'
gem 'html2slim'
```

```
$ docker-compose build
```

既存のビューファイルを slim に書き換え。

```
$ docker-compose run web bundle exec erb2slim app/views app/views
$ docker-compose run web bundle exec erb2slim app/views app/views -d
```


### Bootstrap を導入

見た目を整えるために Bootstrap を使用します。

※ 下記の手順はバージョン5を想定したものになるので、別のバージョンを使いたい場合は他の記事を参考に導入してください。

#### yarn add

必要なライブラリをインストール。

```
$ docker-compose run web yarn add bootstrap @popperjs/core
```

#### app/views/layouts/application.html

```diff_ruby:./app/views/layouts/application.html
- = stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload'
+ = stylesheet_pack_tag 'application', media: 'all', 'data-turbolinks-track': 'reload'
```

「app/views/layouts/application.html」内の9行目あたりに記述されている「stylesheet_link_tag」を「stylesheet_pack_tag」に書き換えます。

#### app/javascript/stylesheets/application.scss

```
$ mkdir -p app/javascript/stylesheets
$ touch app/javascript/stylesheets/application.scss
```

「app/javascript/stylesheets/」以下に「application.scss」を作成し、次の１行を追記してください。

```rb:./app/javascript/stylesheets/application.scss
@import "~bootstrap/scss/bootstrap";
```

#### app/javascript/packs/application.js

「app/javascript/packs/application.js」内に次の2行を追記。

```javascript:./app/javascript/packs/application.js
import "bootstrap";
import "../stylesheets/application";
```

これで Bootstrap の設定は完了です。

### Font Awesome を導入

各種アイコンを使うために Font Awesome を導入します。

```
$ docker-compose run web yarn add @fortawesome/fontawesome-free
```

```js:app/javascript/packs/application.js
// 追記
import "@fortawesome/fontawesome-free/js/all";
```

これだけで OK です。

## 本実装

準備ができたので、本格的な実装に入りましょう。

### simple_calendar をインストール

冒頭でも触れた「[simple_calendar](https://github.com/excid3/simple_calendar)」 という gem をインストールします。

```rb:./Gemfile
gem 'simple_calendar'
```

```
$ docker-compose build
```

### Event モデルを作成

| Event|
| ----|
| title: String |
| start_time: DateTime |
| end_time: DateTime |

今回は

- title
- start_time
- end_time

という3つの属性を持ったシンプルな Event モデルを作成。

中でもポイントは `start_time ` で、simple_calendar はデフォルトの状態だとこの値を基にどの日付なのかを判断してカレンダーに紐付けるようになっています。

```
$ docker-compose run web rails g model Event title:string start_time:datetime end_time:datetime
$ docker-compose run web rails db:migrate
```

```rb:./db/migrate/2022**********_create_events.rb
class CreateEvents < ActiveRecord::Migration[6.1]
  def change
    create_table :events do |t|
      t.string :title, null: false
      t.datetime :start_time, null: false
      t.datetime :end_time, null: false

      t.timestamps
    end
  end
end
```

```rb:./app/models/event.rb
class Event < ApplicationRecord
  validates :title, presence: true
  validates :start_time, presence: true
  validates :end_time, presence: true
end
```

念のためバリデーションも設定。

### コントローラーを作成

```
$ docker-compose run web rails g controller events
```

```rb:./app/controllers/events_controller.rb
class EventsController < ApplicationController
  # フラッシュメッセージを Bootstrap 対応させるための設定
  add_flash_types :success, :info, :warning, :danger

  before_action :set_event, only: %i[edit update destroy]

  def index
    @events = Event.all
  end

  def new
    @event = Event.new
    @default_date = params[:default_date].to_date
  end

  def create
    event = Event.new(event_params)

    if event.save!
      redirect_to events_path, success: "イベントの登録に成功しました"
    else
      render :new
    end
  end

  def edit; end

  def update
    if @event.update!(event_params)
      redirect_to events_path, success: "イベントの更新に成功しました"
    else
      render :edit
    end
  end

  def destroy
    @event.destroy

    redirect_to events_path, success: "イベントの削除に成功しました"
  end

  private
  
    def event_params
      params.require(:event).permit(:title, :start_time, :end_time)
    end

    def set_event
      @event = Event.find(params[:id])
    end
end
```

### ビューを作成

```
$ touch app/views/events/index.html.slim app/views/events/_form.html.slim app/views/events/new.js.erb app/views/events/edit.js.erb 
```

```slim:./app/views/events/index.html.slim
// フラッシュメッセージ
- flash.each do |type, message|
  div class="alert alert-#{type} alert-dismissible rounded-0 fade show" role="alert" 
    button.btn-close aria-label="Close" data-bs-dismiss="alert" type="button" 
    h5.m-0
      = message

.container
  .row.p-3
  = month_calendar(events: @events) do |date, events|
    .d-flex.align-items-center.mb-3
      = date.day
      .ms-auto
        = link_to new_event_path(default_date: date), { class: "btn btn-sm btn-outline-primary fs10", "data-bs-target": "#eventModal", "data-bs-toggle": "modal", remote: true } do
          i.fas.fa-plus
        
    - events&.each do |event|
      .my-1
        = link_to "#{event.title}（#{event.start_time.strftime("%R")} ~ #{event.end_time.strftime("%R")}）", edit_event_path(event), { class: "btn btn-sm btn-success", "data-bs-target": "#eventModal", "data-bs-toggle": "modal", remote: true }

// モーダル画面
#eventModal.modal.fade aria-hidden="true" aria-labelledby="eventModalLabel" tabindex="-1"
  .modal-dialog style="max-width:32%;"
    .modal-content
      .modal-header
        button.btn-close id="modalClose" aria-label="Close" data-bs-dismiss="modal" type="button"
      
      .modal-body
        #form
```

```slim:./app/views/events/_form.html.slim
= form_with model: event, url: url, local: false do |f|
  table.table.table-borderless
    tr
      td = f.label t("activerecord.attributes.event.title"), class: "control-label"
    tr
      td = f.text_field :title, class: "form-control"
    
    tr
      td = f.label t("activerecord.attributes.event.start_time"), class: "control-label"
    tr
      td
        .datetime_select
          = raw sprintf(f.datetime_select(:start_time, with_css_classes: true, default: default_date, use_month_numbers: true, start_year: (Time.current.year), end_year: (Time.current.year + 1), date_separator: '%s', datetime_separator: "%s", time_separator: "%s"), "年 ", "月 ", "日 ", "時 ") + "分"
    
    tr
      td = f.label t("activerecord.attributes.event.end_time"), class: "control-label"
    tr
      td
        .datetime_select
          = raw sprintf(f.datetime_select(:end_time, with_css_classes: true, default: default_date, use_month_numbers: true, start_year: (Time.current.year), end_year: (Time.current.year + 1), date_separator: '%s', datetime_separator: "%s", time_separator: "%s"), "年 ", "月 ", "日 ", "時 ") + "分"
  
  .d-flex.justify-content-end
    - if action_name == "edit"
      = link_to "削除", event_path(event), method: :delete, data: { confirm: "本当に削除しますか？" }, class: "btn btn-danger me-1"
    
    = f.submit class: "btn btn-success"
```

```slim:./app/views/events/new.js.erb
document.getElementById("form").innerHTML = "<%= j(render 'form', event: @event, url: events_path, default_date: @default_date) %>";
```

```slim:./app/views/events/edit.js.erb 
document.getElementById("form").innerHTML = "<%= j(render 'form', event: @event, url: event_path, default_date: nil) %>";
```

デフォルトのデザインだとやや地味なので、カスタマイズ用のビューを準備します。

```
$ docker-compose run web rails g simple_calendar:views
```

上記のコマンドを実行すると `app/views` 配下に自動で次のようなビューファイルが生成されるはず。

```erb:./app/views/simple_calendar/_calendar.html.erb
<div class="simple-calendar">
  <div class="calendar-heading">
    <%= link_to t('simple_calendar.previous', default: 'Previous'), calendar.url_for_previous_view %>
    <span class="calendar-title"><%= t('date.month_names')[start_date.month] %> <%= start_date.year %></span>
    <%= link_to t('simple_calendar.next', default: 'Next'), calendar.url_for_next_view %>
  </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>
      <% date_range.each_slice(7) do |week| %>
        <%= content_tag :tr, class: calendar.tr_classes_for(week) do %>
          <% week.each do |day| %>
            <%= content_tag :td, class: calendar.td_classes_for(day) do %>
              <% if defined?(Haml) && respond_to?(:block_is_haml?) && block_is_haml?(passed_block) %>
                <% capture_haml(day, sorted_events.fetch(day, []), &passed_block) %>
              <% else %>
                <% passed_block.call day, sorted_events.fetch(day, []) %>
              <% end %>
            <% end %>
          <% end %>
        <% end %>
      <% end %>
    </tbody>
  </table>
</div>
```

```erb:./app/views/simple_calendar/_month_calendar.html.erb
<div class="simple-calendar">
  <div class="calendar-heading">
    <%= link_to t('simple_calendar.previous', default: 'Previous'), calendar.url_for_previous_view %>
    <span class="calendar-title"><%= t('date.month_names')[start_date.month] %> <%= start_date.year %></span>
    <%= link_to t('simple_calendar.next', default: 'Next'), calendar.url_for_next_view %>
  </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>
      <% date_range.each_slice(7) do |week| %>
        <tr>
          <% week.each do |day| %>
            <%= content_tag :td, class: calendar.td_classes_for(day) do %>
              <% if defined?(Haml) && respond_to?(:block_is_haml?) && block_is_haml?(passed_block) %>
                <% capture_haml(day, sorted_events.fetch(day, []), &passed_block) %>
              <% else %>
                <% passed_block.call day, sorted_events.fetch(day, []) %>
              <% end %>
            <% end %>
          <% end %>
        </tr>
      <% end %>
    </tbody>
  </table>
</div>
```

```erb:./app/views/simple_calendar/_week_calendar.html.erb
<div class="simple-calendar">
  <div class="calendar-heading">
    <%= link_to t('simple_calendar.previous', default: 'Previous'), calendar.url_for_previous_view %>
    <% if calendar.number_of_weeks == 1 %>
      <span class="calendar-title"><%= t('simple_calendar.week', default: 'Week') %> <%= calendar.week_number %></span>
    <% else %>
      <span class="calendar-title"><%= t('simple_calendar.week', default: 'Week') %> <%= calendar.week_number %> - <%= calendar.end_week %></span>
    <% end %>
    <%= link_to t('simple_calendar.next', default: 'Next'), calendar.url_for_next_view %>
  </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>
      <% date_range.each_slice(7) do |week| %>
        <tr>
          <% week.each do |day| %>
            <%= content_tag :td, class: calendar.td_classes_for(day) do %>
              <% if defined?(Haml) && respond_to?(:block_is_haml?) && block_is_haml?(passed_block) %>
                <% capture_haml(day, sorted_events.fetch(day, []), &passed_block) %>
              <% else %>
                <% passed_block.call day, sorted_events.fetch(day, []) %>
              <% end %>
            <% end %>
          <% end %>
        </tr>
      <% end %>
    </tbody>
  </table>
</div>
```

あとは CSS で調整していきましょう。

```scss:./app/javascript/stylesheets/application.scss
// 追記
.simple-calendar {
  table {
    -webkit-border-horizontal-spacing: 0px;
    -webkit-border-vertical-spacing: 0px;
    background-color: rgba(0, 0, 0, 0);
    border: 1px solid rgb(221, 221, 221);
    border-collapse: collapse;
    box-sizing: border-box;
    max-width: 100%;
    width: 100%;
  }

  tr {
    border-collapse: collapse;
  }

  th {
    padding: 6px;
    border-bottom: 2px solid rgb(221, 221, 221);
    border-collapse: collapse;
    border-left: 1px solid rgb(221, 221, 221);
    border-right: 1px solid rgb(221, 221, 221);
    border-top: 0px none rgb(51, 51, 51);
    box-sizing: border-box;
    text-align: left;
  }

  td {
    padding: 6px;
    vertical-align: top;
    width: 14%;

    border: 1px solid #ddd;
    border-top-color: rgb(221, 221, 221);
    border-top-style: solid;
    border-top-width: 1px;
    border-right-color: rgb(221, 221, 221);
    border-right-style: solid;
    border-right-width: 1px;
    border-bottom-color: rgb(221, 221, 221);
    border-bottom-style: solid;
    border-bottom-width: 1px;
    border-left-color: rgb(221, 221, 221);
    border-left-style: solid;
    border-left-width: 1px;
  }

  .day {
    height: 80px;
  }

  .today {
    background: #ffffc0;
  }

  .prev-month {
    background: #ddd;
  }

  .next-month {
    background: #ddd;
  }
}

.datetime_select {
  .year,
  .month,
  .day,
  .hour,
  .minute {
    display: inline-block;
    width: auto;
    height: calc(1.5em + 0.75rem + 2px);
    padding: 0.375rem 0.75rem;
    font-size: 1rem;
    font-weight: 400;
    line-height: 1.5;
    color: #212529;
    background-color: #fff;
    background-clip: padding-box;
    border: 1px solid #ced4da;
    border-radius: 0.25rem;
    transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
  }
}

.fs10 {
  font-size: 10px;
}
```

### ルーティングを設定

```rb:./config/routes.rb
Rails.application.routes.draw do
  resources :events, except: %i[show]
end
```

### 日本語化対応

最後に「config/application.rb」内に以下を内容を記述し、デフォルトのタイムゾーンや言語を日本に変更してください。

```rb:./config/application.rb
module Myapp
  class Application < Rails::Application
    # ...
    
    # 追記
    config.time_zone = "Tokyo"
    config.active_record.default_timezone = :local
    config.i18n.default_locale = :ja
    config.i18n.load_path += Dir[Rails.root.join("config", "locales", "**", "*.{rb,yml}").to_s]
    
    # ...
  end
end
```

また、「config/locals/」以下にja.ymlを作成します。

```
$ touch config/locales/ja.yml
```

```yml:./config/locales/ja.yml
ja:
  activerecord:
    models:
      event: イベント
    attributes:
      event:
        id: ID
        title: タイトル
        start_time: 開始時刻
        end_time: 終了時刻
        created_at: 登録日
        updated_at: 更新日

  date:
    formats:
      default: "%Y/%m/%d"
      short: "%m/%d"
      long: "%Y年%m月%d日(%a)"

    day_names: [日曜日, 月曜日, 火曜日, 水曜日, 木曜日, 金曜日, 土曜日]
    abbr_day_names: [日, 月, 火, 水, 木, 金, 土]

    month_names:
      [~, 1月, 2月, 3月, 4月, 5月, 6月, 7月, 8月, 9月, 10月, 11月, 12月]
    abbr_month_names:
      [~, 1月, 2月, 3月, 4月, 5月, 6月, 7月, 8月, 9月, 10月, 11月, 12月]

    order:
      - :year
      - :month
      - :day

  time:
    formats:
      default: "%Y/%m/%d %H:%M:%S"
      short: "%y/%m/%d %H:%M"
      long: "%Y年%m月%d日(%a) %H時%M分%S秒 %Z"
    am: "午前"
    pm: "午後"

  simple_calendar:
    previous: 前月
    next: 次月

  helpers:
    submit:
      create: 登録する
      update: 更新する
      submit: 保存する
```

## 動作確認

![スクリーンショット 2022-02-12 16.49.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/c69bf405-f46c-be06-c82c-45453d4d6441.png)

[localhost:3000/events](http://localhost:3000/events) にアクセスしてこんな感じになっていれば完成です。

あとはちゃんと CRUD 機能が動いているか動作確認してください。

![スクリーンショット 2022-02-12 17.02.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/0ab07c09-c4bc-84a2-a5cc-d42e7f4f8daf.png)

## 備考

- simple_calendar を使うためには「start_time」という名前の値が必要となるが、既存のデータに存在しない場合や別の値を使いたい場合は `attribute` という引数に任意の値を渡す事で代用可能。
    - https://github.com/excid3/simple_calendar#rendering-events

## あとがき

以上、「simple_calendar」という gem で文字通りシンプルなカレンダー機能を実装してみました。

公式ドキュメントを見た感じだと他にも色々カスタマイズの余地がありそうだったので、気になる方はいじってみてください。

https://github.com/kazama1209/rails-simple-calendar-app