Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: カレンダーの「繰り返しイベント」を実装しよう(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

Rails: カレンダーの「繰り返しイベント」を実装しよう(翻訳)

先週、Rails DesignerのUI Componentsのv1.14をリリースしました。このリリースから、完全にカスタマイズ可能なカレンダーコンポーネントを搭載ししました。構築にはViewComponentを、画面デザインにはTailwind CSSを使っています。

リリーズ後に、「これは本当に使えるのですか?」という問い合わせメールを2件いただきました。はい、もちろん使えます。
このカレンダーコンポーネントはeventsという配列/コレクションを渡せるようになっているだけであり、この種の機能はUIコンポーネントの範疇(とサポート範囲)には入らないものですが、たまたま私はこの機能が必要となる作業に取り組んでいるところです。
カレンダーコンポーネントを使うことについては何も難しくありません(作成イベント件数が数十万件になれば少々難しくなりますが😬)。本記事でカレンダーコンポーネントの使い方を共有するうえで、何か他にできることはあるでしょうか?

本記事に対応するリポジトリは以下です(ただし有料のカレンダーコンポーネントは含まれていません!)。必ずbin/rails db:seedを実行してからお使いください。

rails-designer-repos/recurring-events - GitHub

繰り返しルールの基本部分はice_cubeというgemで構築しています(この種のgemはいろいろありますが、私が使い慣れているのはこれです)。このgemは、JSONシリアライズされたルールセットをEventモデル内に保存することで動作します(この例ではrecurring_rulerecurring_until)。Eventモデルでは、「every Monday(毎週月曜日)」や「first day of month(月の最初の日)」といったパターンを定義します。
ice_cube gemが提供するメソッドは、これらのルールを実際の日付の繰り返しに拡張したり、「例外」「特定の曜日」「月ごと/年ごと」といったルールや、そうしたルールの組み合わせによる複雑な繰り返しパターンを処理します。

ice-cube-ruby/ice_cube - GitHub

それでは、bundle add ice_cubeを実行してice_cube gemを追加しましょう。

次に、以下を実行してEventモデルを作成します。

rails g model Event title description:text start:datetime end:datetime recurring_rule:string recurring_until:datetime

簡単ですね。
私が気に入っているのは、@events = Event.all.include_recurringのようなAPIです。これには適切なデフォルト値が設定されていて、デフォルトのタイムフレームをオーバーライドしたい場合は@events = Event.all.include_recurring(within: 1.month.from..2.months.from_now)のように書けます。なかなか良さそうですよね?

処理は以下のRecurrence concernで行います。

module Event::Recurrence
  extend ActiveSupport::Concern

  included do
    serialize :recurring_rule, coder: JSON
  end

  class_methods do
    def include_recurring(within: Time.current..6.months.from_now)
      events = all.to_a

      recurring_events = events.select(&:recurring_rule).flat_map do |event|
        event.schedule.occurrences_between(within.begin, within.end).map do |date|
          next if date == event.starts_at

          Event::Recurring.new(
            event,
            starts_at: date,
            ends_at: date + (event.ends_at - event.starts_at)
          )
        end
      end.compact

      (events + recurring_events).sort_by(&:starts_at)
    end
  end

  def schedule
    @schedule ||= IceCube::Schedule.new(starts_at) do |schedule|
      schedule.add_recurrence_rule(IceCube::Rule.from_hash(JSON.parse(recurring_rule))) if recurring_rule
    end
  end

  class Event::Recurring
    include ActiveModel::Model

    delegate :title, :description, :recurring_rule, :schedule, :to_param, to: :@event
    attr_reader :starts_at, :ends_at

    def initialize(event, starts_at:, ends_at:)
      @event = event
      @starts_at = starts_at
      @ends_at = ends_at
    end

    def persisted? = false
  end
end

何だか凄そうに見えますが、そんなに難しくありません!
最も興味深い部分はinclude_recurringメソッドで、ここではすべてのイベントをチェックしてから、今後繰り返されるイベントを生成します。イベントの定期的な繰り返しは軽量なEvent::Recurringオブジェクトで表現します。これは通常のイベントと同様に振る舞いますが、メモリ上にのみ存在する点が異なります(元のイベントをミラーリングして日付を調整しています)。このconcernでは、通常のイベントと生成イベントを組み合わせた結果をstarts_atでソートして返します。こうすることで、繰り返しをデータベースに保存せずに、1回きりのイベントと繰り返しイベントの両方を含むすべてのイベントリストを得られるようになります。素晴らしいですね!

このRecurrence concernを忘れずにEventモデルにincludeしましょう。

class Event < ApplicationRecord
  include Recurrence
end

これで繰り返しイベントの基本部分ができました!

🔗 新規イベントを作成する

本記事で使っているrecurring-eventsリポジトリには、新規の(繰り返し)イベントをリスト表示/作成する基本部分が既に含まれています。ほとんどはRailsの基本的なコードですが、ここでBuilder concernに触れておきたいと思います。

# app/models/event/recurrence/builder.rb
module Event::Recurrence::Builder
  extend ActiveSupport::Concern

  included do
    attr_accessor :recurring_type, :recurring_until

    before_save :set_recurring_rule
  end

  private

  def set_recurring_rule
    return if recurring_type.blank?

    rule = case recurring_type
      when "daily" then IceCube::Rule.daily
      when "weekly" then IceCube::Rule.weekly.day(starts_at.wday)
      when "biweekly" then IceCube::Rule.weekly(2).day(starts_at.wday)
      when "monthly" then IceCube::Rule.monthly.day_of_month(starts_at.day)
    end

    rule = rule.until(recurring_until) if recurring_until.present?

    self.recurring_rule = rule.to_hash.to_json
  end
end

このBuilder concernは、繰り返しイベントをフォーム形式からデータベースに変換する処理を担当しています。フォームに仮想属性(recurring_typerecurring_until)を追加して、それらをIceCubeのルールに変換してから保存します。どの日付から繰り返すかは、イベントのstarts_atで決定されます。これで、火曜日から開始される週イベントは常に火曜日に繰り返されるようになります。

このBuilder concernもEventモデルにincludeすることをお忘れなく。

include Recurrence::Builder

もちろん、例外の扱い(特定の繰り返しをキャンセル・変更するなど)や、曜日を複数指定する(「月曜と木曜」など)、以後の繰り返しだけを編集する、といったよくある機能も追加できます。これは、そうした機能を追加する基盤としても適しています。

関連記事

Rails: ビューをきれいに書くのに便利な知られざるヘルパー10種(翻訳)

Railsフロントエンド: HotwireとTailwind CSSでモーダルを作る(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。