Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

Ruby / Railsにおけるカレンダー機能を振り返る

こんにちは、ForSchool事業部サーバーサイドエンジニアのましばです。

Studyplus for Schoolでは3月にカレンダー機能をリリースしました。 色々と大変なこともあったので振り返りを含めて記事にしたいと思います。

iCalendarについて

カレンダー機能では、生徒や先生が登録した学習計画をiOSやAndroidのカレンダーアプリ上でも確認できる必要があります。
今回の実装ではカレンダーアプリと連携する手段としてiCalendarを使用しました。
iCalendarはカレンダーやスケジュールをインターネット上でやりとりするためのデータフォーマットであり、RFC5545で定義されています。

iCalendarでは、予定はイベントコンポーネントによって定義されます。
例えば、ある単発の予定は以下のようなイベントとして記述できます。

BEGIN:VEVENT
DTSTART:20210521T190000Z
DTEND:20210521T200000Z
SUMMARY:ブログ記事を書く
END:VEVENT

カレンダーといえば繰り返しの予定ですが、これはイベント内にRRULEを追加することで対応できます。

BEGIN:VEVENT
DTSTART;TZID=Asia/Tokyo:20210521T120000
DTEND;TZID=Asia/Tokyo:20210521T130000
RRULE:FREQ=DAILY
SUMMARY:お昼ごはん
END:VEVENT

このように記述すると、毎日繰り返しの予定を定義することができます。

RRULEにはいくつか設定できる項目があります。以下に例を挙げます。

  • INTERVAL: 予定を繰り返す間隔。DAILY;INTERVEL=2とすれば1日おきの予定になります。
  • UNTIL: 繰り返しの終了を設定できます。
  • COUNT: 予定を繰り返す回数を設定できます。

繰り返す条件も様々な指定方法があり、毎日、毎週、毎月や日付指定、曜日指定なども可能です。

その他に、予定を作成しない例外の条件としてEXRULEEXDATEを指定することができます。
例えば以下のようにEXDATEを設定すると、5月31日にこの繰り返しの予定は定義されないようになります。

BEGIN:VEVENT
DTSTART;TZID=Asia/Tokyo:20210521T120000
DTEND;TZID=Asia/Tokyo:20210521T130000
RRULE:FREQ=DAILY
EXDATE;TZID=Asia/Tokyo:20210531T120000
SUMMARY:お昼ごはん
END:VEVENT

ice_cubeによる実装

ForSchool事業部ではサーバーサイドをRuby / Railsで開発しています。
iCalendarを扱うgemはいくつかありますが、繰り返しの予定を多く扱うことや、ドキュメントの量なども加味して、今回はice_cubeを利用しました。
使い方は直感的で、IceCube::Scheduleクラスのインスタンスを作成し、必要な条件をメソッドで設定していくだけです。

schedule = IceCube::Schedule.new(Time.zone.parse('2021-05-21 10:00:00', end_time: Time.zone.parse('2021-05-21 11:00:00'))
# 毎日繰り返しの予定として設定
schedule.add_recurrence_rule(IceCube::Rule.daily)
# 22日から30日までに存在する予定を取得
schedule.occurrences_between(Time.new(2021, 05, 22), Time.new(2021, 05, 30))
# => [Sat, 22 May 2021 10:00:00 JST +09:00,
#  Sun, 23 May 2021 10:00:00 JST +09:00,
#  Mon, 24 May 2021 10:00:00 JST +09:00,
#  Tue, 25 May 2021 10:00:00 JST +09:00,
#  Wed, 26 May 2021 10:00:00 JST +09:00,
#  Thu, 27 May 2021 10:00:00 JST +09:00,
#  Fri, 28 May 2021 10:00:00 JST +09:00,
#  Sat, 29 May 2021 10:00:00 JST +09:00]

# 25日は例外に設定
schedule.add_exception_time(Time.zone.parse('2021-05-25 10:00:00'))
# 24日の次の予定が26日になっている
schedule.next_occurrence(Time.zone.parse('2021-05-24 10:00:00'))
# => Wed, 26 May 2021 10:00:00 JST +09:00

大変だった点

繰り返しデータのモデル化

カレンダーにおいて、繰り返しの予定は終了期限を設定しない限り半永久的な予定となります。
そのため、全ての発生するイベントをデータとして保持することは現実的ではありません。
今回の実装では、開始日の予定と繰り返し条件のみデータとして保持し、その後の予定はice_cubeのメソッドにより取得するようにしました。
なので、あるユーザーの特定の期間の予定を取得する場合は

  1. ユーザーの開始日の予定を取得
  2. 繰り返し条件と組み合わせて繰り返しの予定を取得
  3. 期間内に含まれるものを返す

といった処理になります。
なかなか複雑な実装だったので、検証するのが大変でした。

予定の編集、削除処理

最も苦労したことが、予定の編集と削除に伴うデータの更新処理でした。
例えば、ある日付以降は異なる条件の繰り返し予定に編集したい場合があります。
その場合、iCalendarでは古い条件にUNTILを設定し、新たなイベントを追加で作成します。

BEGIN:VEVENT
DTSTART;TZID=Asia/Tokyo:20210521T120000
DTEND;TZID=Asia/Tokyo:20210521T130000
RRULE:FREQ=DAILY
UNTIL;TZID=Asia/Tokyo:20210531T235959
SUMMARY:お昼ごはん
END:VEVENT

BEGIN:VEVENT
DTSTART;TZID=Asia/Tokyo:20210601T121000
DTEND;TZID=Asia/Tokyo:20210601T131000
RRULE:FREQ=DAILY
SUMMARY:10分からお昼ごはん
END:VEVENT

一方で、もともとの予定の開始日からすべての予定を変更したい場合は、単に条件を書き換えるだけで可能です。

BEGIN:VEVENT
DTSTART;TZID=Asia/Tokyo:20210521T121500
DTEND;TZID=Asia/Tokyo:20210521T131500
RRULE:FREQ=DAILY
SUMMARY:15分からお昼ごはん
END:VEVENT

このように、予定全てを変更するか、予定の途中以降を変更するかで期待する動作が異なります。
利用しているユーザーが予定を変更する時、その予定が開始予定日のものかそれ以降のものをかを意識することはありません。このため、リクエストが来た際に変更日時が予定開始日に相当するのかそれ以降の日時なのかをサーバー側で判断し、その後の処理を分岐させていく必要があります。
この他にも、変更条件や予定のバリデーションなども含めると想定が必要なケースがかなり増えてしまい、テストがとても大変でした。

最後に

非常にざっくりとでしたが、カレンダー機能の実装について振り返りました。
スケジュール的にもかなりタイトで大変でしたが、大きな不具合もなくリリースできてよかったです。
また、世の中のカレンダーアプリがいかにすごいかを実感することになりました。Googleカレンダーは半端ないですね。