🔁

RRULEで学ぶ繰り返し予定の実装

に公開

これはTimeTree Advent Calendar 2025の1日目の記事です。

はじめに

カレンダーアプリを開発していると、必ずと言っていいほど直面するのが「繰り返し予定」の実装です。TimeTreeの開発では、この繰り返し予定の実装に本当に苦しめられてきました。


TimeTree Web版の繰り返し設定画面

「毎週水曜日の英会話レッスン」「毎月第2・第4火曜日の燃えないゴミの日」「平日だけのリマインダー」など、ユーザーは様々な繰り返しパターンを期待します。さらに厄介なのが、繰り返された予定の特定の日付だけ時間を変えたり、予定をキャンセルしたりできる必要があることです。

ある程度使えるスケジュール帳を作るなら、繰り返し機能は避けて通れません。この記事では、繰り返し予定を表現するための標準フォーマットである RRULE(Recurrence Rule) について、実際の開発経験を交えながら解説します。

RRULEとは

RRULEは、繰り返し予定のルールを定義するためのフォーマットです。RFC 5545 (iCalendar) で定義されており、Googleカレンダーや Outlook など、多くのカレンダーアプリケーションで採用されています。

https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.10

標準化されたフォーマットを使用することで、異なるシステム間での予定の相互運用性が保たれ、車輪の再発明を避けることができます。

実践的なユースケース

構文の説明に入る前に、まずは具体的なユースケースを見てみましょう。実際の生活で使いそうなシーンを想像すると、RRULEがどんな問題を解決するのかがイメージしやすくなります。

基本的な繰り返しパターン

毎週水曜日の英会話レッスン:

RRULE:FREQ=WEEKLY;BYDAY=WE

毎月 第2・第4火曜日の燃えないゴミの日:

RRULE:FREQ=MONTHLY;BYDAY=2TU,4TU

毎年7月1日の会計年度開始日:

RRULE:FREQ=YEARLY;BYMONTH=7;BYMONTHDAY=1

平日だけの業務日報リマインダー(毎週 月〜金):

RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR

よくある実用的なパターン

月末の給与振込日(31日がない月にも対応):

RRULE:FREQ=MONTHLY;BYMONTHDAY=-1

3週間おきの水曜日の通院日:

RRULE:FREQ=WEEKLY;INTERVAL=3;BYDAY=WE

あと5回だけ繰り返す習い事の残りレッスン:

RRULE:FREQ=WEEKLY;COUNT=5

2025年12月31日までの毎日のタスク:

RRULE:FREQ=DAILY;UNTIL=20251231T000000Z

高度なパターン(BYSETPOSの活用)

毎月の最終金曜日のプレミアムフライデー:

RRULE:FREQ=MONTHLY;BYDAY=FR;BYSETPOS=-1

毎月「最終火曜日または最終木曜日のいずれか遅い方」の定例会議:

RRULE:FREQ=MONTHLY;BYDAY=TU,TH;BYSETPOS=-1

このように、RRULEを使えば日常生活やビジネスで必要な様々な繰り返しパターンを簡潔に表現できます。

RRULEの構文形式

RRULEは以下のような構文で表現されます:

RRULE:FREQ=FREQ_VALUE;[BYxxx=xxx,...];[INTERVAL=n];[UNTIL=日時] or [COUNT=n];...

構文は単純で、RRULE: で始まって、パラメータをセミコロン ; で区切っていくだけです。各パラメータは大文字英字の「キー = 値」形式で記述します。複数の値を指定したい場合はカンマ , で区切ります。

日時は YYYYMMDD[T]HHMMSSZ 形式(UTC時刻)で指定します。なお、UNTILCOUNT は併用できないので注意してください。パラメータの順番に決まりはありませんが、慣習的に FREQ は最初に書かれることが多いです。

基本パラメータ

FREQ, UNTIL, COUNT, INTERVAL

繰り返しの基本を定義する最も重要なパラメータです。

FREQ は繰り返しの単位を指定します。DAILY(日単位)、WEEKLY(週単位)、MONTHLY(月単位)、YEARLY(年単位)の4つから選びます。

繰り返しをいつまで続けるかは、UNTILCOUNT で指定します。UNTIL は終了日時(例:UNTIL=20251231T000000Z で2025年12月31日まで)、COUNT は回数(例:COUNT=5 で5回繰り返す)を表します。ただし、この2つは併用できないので注意してください。

INTERVAL は繰り返しの間隔を指定します。デフォルトは1ですが、例えば FREQ=DAILY;INTERVAL=2 と書けば2日おきになります。

BYDAY, BYMONTHDAY, BYYEARDAY

特定の日付や曜日を指定するパラメータです。

BYDAY は曜日を指定します。MOTUWETHFRSASU で月曜から日曜を表します。さらに、1MO と書けば第1月曜日、3TH なら第3木曜日を指定できます。負の値も使えて、-1FR で最終金曜日を表現できます。

BYMONTHDAY は月内の日付を指定します。例えば BYMONTHDAY=15 で毎月15日です。こちらも負の値が使えて、BYMONTHDAY=-1 と書けば月末を指定できます(31日がない月でも大丈夫)。

BYYEARDAY は年内の日付を指定します。例えば BYYEARDAY=200 で年内の第200日です。

拡張パラメータ

より複雑な繰り返しパターンを表現したい場合は、拡張パラメータを使います。BYWEEKNO は週番号による指定、BYMONTH は特定の月に絞り込み、BYSETPOS はフィルターされた結果のn番目を選択(例:第1月曜)、WKST は週の開始曜日を指定(例:MO)できます。

EXDATE / RDATE で例外を扱う

RRULEだけでは表現しきれない例外や特別な追加日を指定するためのプロパティです。RRULE: と同じように、EXDATE:RDATE: のように記述します。

EXDATE(除外日)

繰り返し予定のうち、除外したい特定日を明示的に指定します。

EXDATE:20250716T090000Z,20250920T090000Z

例えば、毎週水曜日の予定から祝日だけを除外したいときや、ユーザーが個別に「この日は中止」とした場合に使います。

RDATE(追加日)

RRULEの結果に含まれない個別の追加日を明示的に指定します。

RDATE:20250717T090000Z

「第3水曜」に加えて「臨時で第5水曜も追加」といった特例や、不定期イベントで一部だけ繰り返し以外の日がある場合に使います。

RRULEをパースする rrule.js ライブラリの使い方

TimeTreeではバックエンドの負荷を減らすために、繰り返し予定はiOS、Android、Web などクライアントごとに展開処理をしています。

Web版TimeTreeでは、予定データの中に含まれるRRULEを扱うためにrrule.jsというライブラリを使っています。rrule.jsはRRULE構文のパーサー、内部表現(RRuleオブジェクト)、日付一覧の出力機能などを提供しているライブラリです。

https://github.com/jakubroztocil/rrule

文字列からのパース

rrulestr 関数を使用すると、RRULE、EXDATEなど複数のRRULE情報を一度にパースできます。予定の開始日をDTSTARTとして追加し、改行で区切ります。

import { rrulestr } from 'rrule';

const rule = rrulestr([
  "DTSTART:20240101T000000Z",
  "RRULE:FREQ=WEEKLY;BYWEEKDAY=MO,FR;COUNT=5"
].join("\n"));

console.log(rule.all());
// => [2024/1/1, 1/5, 1/8, 1/12, 1/15]

JavaScript構造体からの生成

同様の値をJavaScriptの構造体としても表現できます。これは繰り返し設定フォームなどで便利です。

import { RRule } from 'rrule';

const rule = new RRule({
  freq: RRule.WEEKLY,
  interval: 1,
  byweekday: [RRule.MO, RRule.FR],
  dtstart: new Date(Date.UTC(2024, 0, 1)),
  count: 5
});

console.log(rule.all());
// => [2024/1/1, 1/5, 1/8, 1/12, 1/15]

RRuleオブジェクトから繰り返す日付を生成

RRuleオブジェクトの .all() メソッドを呼ぶと結果の日付をDateオブジェクトの配列で返します。ただし、このDateのタイムゾーンはUTCになっているので注意が必要です。TimeTreeの実装では、rrule.jsへの入出力は毎回ローカルタイム⇄UTCの変換をかませています。

rule.all(); // [2024/1/1, 1/5, 1/8, 1/12, 1/15]

UNTILCOUNTのないRRULEでの.all()に注意

UNTILCOUNT が指定されていないRRULEに対して .all() を呼ぶと、rrule.jsの反復エンジンは9999年までの繰り返しを生成します。これは移植元のPythonライブラリの上限(datetime.MAXYEAR)に由来するものです。

// 期限の設定されていないRRULE
const rule = new RRule({
  freq: RRule.WEEKLY,
  byweekday: [RRule.MO]
});

// これを実行すると9999年までの毎週月曜日(約41万件)が生成される
// rule.all();

実際にブラウザ環境で検証したところ、COUNTUNTILを含まないルールで.all()を呼び出すと、以下の件数の日付が生成されました。

ルール頻度 生成される日付数
DAILY 2,913,539件 (約291万件)
WEEKLY 416,221件 (約41万件)
MONTHLY 95,724件 (約9万5千件)
YEARLY 7,977件 (約8千件)

DAILYWEEKLY では膨大な件数になるため、ブラウザがフリーズしたりパフォーマンスが劣化する可能性が高いです。

イテレータ関数で逐次処理

.all() にイテレータ関数を渡すことで、必要に応じて逐次結果を生成できます。これにより、大量の日付生成を途中で止めることができます。

rule.all((date, i) => {
  console.log(date);
  // 10件取得したら処理を中断
  return i < 10;
});

.between()で範囲を指定

実際のカレンダーUIでは、表示範囲が決まっているため、その範囲内の日付だけを生成する方がパフォーマンスに優れています。例えば、2025年12月の月曜日開始のカレンダービューを表示する場合、12月1日〜2026年1月4日の範囲で生成します。

// カレンダー表示範囲で日付を生成
const startDate = new Date(Date.UTC(2025, 11, 1)); // 2025年12月1日
const endDate = new Date(Date.UTC(2026, 0, 4));     // 2026年1月4日

const dates = rule.between(startDate, endDate, true); // 第3引数trueで境界を含む
// => [2025/12/1, 12/8, 12/15, 12/22, 12/29, 2026/1/5]

TimeTreeでも、カレンダービューに表示する日付範囲を指定して .between() を使用することで、無限ループを避けつつ効率的に予定を生成しています。

TimeTreeでのRRULE実装の苦労話

TimeTreeで繰り返し予定を実装する際、特に苦労したポイントをいくつか紹介します。

繰り返し予定を予定をオンデマンドに実体化する

全ての繰り返し予定を実際のデータとして登録すると、データ量が膨大になってしまいます。というより、RRULEは仕様上UNTILCOUNTなどの上限を設定しないと、日付が無限に生成されてしまうので、全てを実体化するのは現実的に不可能です…。そのため、TimeTreeでは普段はオリジナル予定のエイリアスとして扱い、必要な時だけ実体化する設計にしています。

具体的には、編集やコメントをした瞬間に、その日付の独立した予定としてコピーを作成し、オリジナル予定にEXDATEを追加してその日を除外します。この設計により、「この回だけ参加者を追加したい」「この回だけコメントで補足したい」といった柔軟な運用ができるのですが、データ構造と同期処理がかなり複雑になっています。

繰り返し予定を編集する際の実装イメージ
async function editRepeatedEvent(event) {
  // 元の予定をコピーした子予定を作成
  await createEvent({ ...event });
  // 元の予定にEXDATEを追加
  await updateEvent(event.id, {
    ...event,
    recurrences: [...event.recurrences, "EXDATE:20250522"]
  });
}

互換性の考慮

前述の通り、RRULEのパース処理は各クライアントで行なっています。そのため、クライアント間でRRULEの互換性を考慮する必要があります。例えばEXDATEに時刻を含めるかどうかでパース結果も異なるため、仕様をクライアント間で揃えておく必要があります。

また、TimeTreeには外部で作られた予定のインポート機能があります。OSからはiCalendarに準拠したフォーマットで渡されるため、RRULEも取り込むことができます。RFC違反のフォーマットも取り込まれる可能性があり、サーバー側でもバリデーションが必要になります。

バックエンドからの予定取得の難しさ

バックエンドから予定を取得する際に、繰り返しを考慮したAPIにするのが難しい問題があります。

前述のように、バックエンドには繰り返しデータは展開されておらず、RRULEだけが保存されています。例えば11月から毎週繰り返されている予定があったときに、12月のカレンダーを見る場合、データベースには11月の予定しかないため、単純な日付範囲での検索が難しくなります。

これを解消するために、TimeTreeでは全ての予定に「存在しうる最後の日付」を事前に計算してデータベースに持たせています(カラム名は until_at)。検索の際は、表示しているカレンダーの期間に「開始日〜until_at」が含まれる予定を検索し、クライアント側でRRULEをパースして表示されうる日付に予定をマッピングしています。

特殊なケース:旧暦対応

旧暦については去年のアドベントカレンダーで解説しました。例えば「毎年の誕生日を旧暦で登録しておく」などのユースケースに便利です。
https://zenn.dev/timetree/articles/17eb4682af404b

旧暦からグレゴリオ暦への変換処理は別途内部で実装しており、例えば「毎年旧暦4月25日」をグレゴリオ歴にすると「2025年5月22日」「2026年6月10日」「2027年5月30日」...と計算することができます。しかし、旧暦の繰り返しはRRULEでは表現できないため、RDATE を使用することで表現しています。

RDATE:20250522,20260610,20270530,...

まとめ

RRULEは複雑な繰り返し予定を簡潔に表現できる強力なフォーマットです。標準化されたフォーマットなのでシステム間の相互運用性が高く、基本パラメータ(FREQ, UNTIL, COUNT, INTERVAL)だけで多くのユースケースに対応できます。BYDAYBYMONTHDAYBYSETPOS などを使えば柔軟な繰り返しパターンを表現でき、EXDATE/RDATEで例外的なケースにも対応可能です。rrule.js のようなライブラリを使えばRRULEを扱うこと自体は特段難しくはありません。

しかし、TimeTreeの開発を通じて学んだのは、繰り返し予定の実装は想像以上に複雑だということです。単にRRULEを保存するだけでは済まず、以下のような課題に直面します。

データ管理の工夫

繰り返し予定を全て実体化するのは現実的ではないため、オンデマンドで実体化する仕組みが必要です。編集やコメント時にコピーを作成し、元の予定に EXDATE を追加する設計により柔軟性を実現できますが、データ構造と同期処理は複雑になります。

バックエンドからの予定取得

繰り返しデータはRRULEだけが保存されているため、単純な日付範囲での検索が困難です。「存在しうる最後の日付」を事前計算してデータベースに持たせ、クライアント側でRRULEをパースする設計が有効です。

クライアント間の互換性

iOS、Android、Webなど複数クライアントでRRULEを展開する場合、EXDATE に時刻を含めるかどうかなど、細かい仕様の統一が重要です。外部からのインポート機能では、RFC違反のフォーマットへの対応も必要になります。

ライブラリの癖への対応

rrule.js は日付をUTCとして扱うため、ローカルタイム⇄UTCの変換が毎回必要です。また、UNTILCOUNTのないRRULEで.all()を呼ぶと9999年までの膨大な日付が生成されるため、.between()での範囲指定が推奨されます。

最後に

カレンダー機能を実装する際は、独自のフォーマットを作るのではなく、RRULEのような標準仕様を活用することをお勧めします。ただし、「標準を使えば簡単」というわけでは全然なくて、ビジネスロジックに合わせた設計と実装には多くの工夫が必要です。

繰り返し機能を持ったカレンダーを作っている方は同じような辛さを抱えていると思います。この記事が同じ課題に取り組む方の助けになれば幸いです。

参考資料

こちらは同僚の記事です。全体がよくまとまっているので社内でもよく参考にしています。
https://qiita.com/lciel/items/31c2d6109283a9bbb156

TimeTree Tech Blog

Discussion