カレンダーの繰り返し予約のRRULEを学ぶ

## はじめに
今回はカレンダーの繰り返し予約のRRULEを学んでいきます。カレンダーで、「このMTGは8月まで毎日10時〜11時に設定する」などで利用するあれです！繰り返し予約は全部DBに保存しておくのではなく、ルールだけ保存しておいてフロント側で展開しています。

![スクリーンショット 2025-05-28 9.15.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/599049/ac7ee8d1-a641-4abc-9075-a72d3fca80bb.png)

## そもそもRRULEとは何か？

カレンダーアプリで「毎週の会議」や「毎月の締め切り」のような**繰り返し予定**を扱いたいときに使われるのが `RRULE`（Recurrence Rule）です。

これは[RFC 5545（iCalendar仕様）](https://tex2e.github.io/rfc-translater/html/rfc5545.html#3-3-10--Recurrence-Rule)で定義されている、繰り返しルールを表すフォーマットです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/599049/bb1602f7-7313-48fe-ba75-7a7ceaae6c70.png)

## 基本構文

`RRULE`（繰り返しルール）は、カレンダーイベントの繰り返しを定義する文字列で、以下のような形式で記述します。

`RRULE:FREQ=FREQUENCY;INTERVAL=n;COUNT=x;UNTIL=yyyyMMddTHHmmssZ;BYxxx=…`

| パラメータ   | 説明                                               | 例                                          |
|--------------|----------------------------------------------------|---------------------------------------------|
| `FREQ`       | 繰り返しの頻度（必須）                            | `FREQ=WEEKLY` → 毎週                        |
| `INTERVAL`   | 繰り返しの間隔。省略時は1                          | `INTERVAL=2` → 2週ごと                      |
| `COUNT`      | 繰り返す回数                                       | `COUNT=10` → 10回繰り返して終了            |
| `UNTIL`      | 繰り返しの終了日時（UTC形式）                      | `UNTIL=20251231T235959Z`                    |
| `BYDAY`      | 曜日を指定（`MO`＝月曜、`TU`＝火曜…）              | `BYDAY=MO,WE` → 月・水曜に繰り返す         |
| `BYMONTHDAY` | 月内の日付を指定                                   | `BYMONTHDAY=15` → 毎月15日に実行           |
| `BYMONTH`    | 月を指定                                           | `BYMONTH=1,7` → 1月と7月のみ実行           |
| `EXDATE`     | 繰り返しから除外する日付（UTC形式でカンマ区切り） | `EXDATE=20240510T000000Z` → 5/10を除外     |

## 実践
`react-big-calendar`で実践してみましょう！

<details><summary>環境作成</summary>

```tree:ディレクトリ構成
~/develop/rrule-big-calendar-demo  (main)$ tree src/
src/
├── App.css
├── App.tsx
├── assets
│   └── react.svg
├── index.css
├── lib
│   ├── localizer.ts
│   └── rruleHelpers.ts
├── main.tsx
└── vite-env.d.ts

3 directories, 8 files
```

```tsx:src/App.tsx
import { useMemo, useState } from "react";
import { Calendar } from "react-big-calendar";
import "react-big-calendar/lib/css/react-big-calendar.css";
import { rruleToEvents } from "./lib/rruleHelpers";
import { localizer } from "./lib/localizer";

// rruleパターン生成関数
function getRRulePattern(): string {
	// JST 2024年5月1日 9:00 → UTC 2024年5月1日 0:00
	const dtstart = "20240501T000000Z";
	return `DTSTART:${dtstart}\nRRULE:FREQ=DAILY;COUNT=365`;
}

function App() {
	const [date, setDate] = useState(new Date("2024-05-01T00:00:00Z"));

	const rrule = getRRulePattern();
	console.log("rrule", rrule);

	// 2時間（120分）のイベント
	const events = useMemo(
		() => rruleToEvents(rrule, 120, "毎日のイベント"),
		[rrule],
	);

	return (
		<div style={{ height: "100vh", width: "100vw", margin: 0, padding: 0 }}>
			<Calendar
				localizer={localizer}
				events={events}
				defaultView="week"
				date={date}
				onNavigate={setDate}
				style={{ height: "100%", width: "100%" }}
			/>
		</div>
	);
}

export default App;

```

```ts:src/lib/localizer.ts
import { format, parse, startOfWeek, getDay } from "date-fns";
import { ja } from "date-fns/locale";
import { dateFnsLocalizer } from "react-big-calendar";

export const localizer = dateFnsLocalizer({
	format,
	parse,
	startOfWeek,
	getDay,
	locales: { ja },
});
```

```ts:src/lib/rruleHelpers.ts
import { RRule } from "rrule";
import type { Event } from "react-big-calendar";

/**
 * RRULE 文字列を react-big-calendar の events[] に展開
 */
export function rruleToEvents(
	rruleString: string,
	durationMinutes: number,
	title: string,
): Event[] {
	// RRULE文字列をパース
	const rrule = RRule.fromString(rruleString);

	// 繰り返しの日付を生成
	const dates = rrule.all();

	// イベントの配列を生成
	return dates.map((date, index) => ({
		id: index,
		title,
		start: date,
		end: new Date(date.getTime() + durationMinutes * 60000),
	}));
}
```
</details>

では実践していきます！！

## パターン1:平日のみ毎日イベント
`DTSTART:20240429T000000Z
RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;COUNT=260`

```ts:
// rruleパターン生成関数
function getRRulePattern(): string {
	// JST 2024年4月29日 9:00 → UTC 2024年4月29日 0:00
	const dtstart = "20240429T000000Z";
	return `DTSTART:${dtstart}\nRRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;COUNT=260`;
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/599049/e1fc0a64-c57e-438f-a654-23262e59b779.png)

→土曜と日曜が表示されなくなります。

## パターン2:毎月第1月曜に定例会議を表示

```ts:
// rruleパターン生成関数
function getRRulePattern(): string {
	// 2024年5月6日（月）9:00 JST → 0:00 UTC
	const dtstart = "20240506T000000Z";
	return `DTSTART:${dtstart}\nRRULE:FREQ=MONTHLY;BYDAY=1MO;COUNT=12`;
}
```

![month kurikaeshi.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/599049/80679db3-95ff-40ad-b367-d14a8cd5f8d3.gif)

→余談ですが、`COUNT=12`をつけないと白飛びします。原因は無限に繰り返そうとして、メモリを食い切ることが原因のようです。繰り返しの範囲を指定する方法もあるんですが、一旦置いておきましょう。

## パターン3:期間限定の毎週水曜キャンペーン（6月〜8月）

```ts:
// rruleパターン生成関数（夏のキャンペーン：毎週水曜）
function getRRulePattern(): string {
	// JST 2024年6月5日（水）9:00 → UTC 0:00
	const dtstart = "20240605T000000Z";
	const until = "20240828T000000Z";
	return `DTSTART:${dtstart}\nRRULE:FREQ=WEEKLY;BYDAY=WE;UNTIL=${until}`;
}
```


![month_maishu.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/599049/6b77e900-a37b-4076-8923-f2fff3bc1b94.gif)

## パターン4:特定の日だけ繰り返しを除外する

`DTSTART:20240605T000000ZRRULE:FREQ=WEEKLY;BYDAY=WE;UNTIL=20240828T000000ZEXDATE:20240612T000000Z`

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/599049/30ded288-3d60-439e-bdec-cf4851f202cf.png)