# イベント、繰り返し、時刻の扱い

> Calendarpit における繰り返しイベントの展開、招待や会議のローカルシミュレーション、タイムゾーン、夏時間（DST）、busy 区間の扱いについて説明します。

Calendarpit は、次の処理をすべてローカルで行います。

- 繰り返しの系列の展開
- ローカルのテストユーザー間での招待の同期
- 時間範囲と busy 区間の計算

対応しているエンドポイントとフィールドの一覧は、[Calendar API の互換性](https://calendarpit.dev/ja/compatibility.md)を参照してください。

## 繰り返しイベント

繰り返しイベントは、系列のまま取得することも、各回に展開して取得することもできます。

`events.list` では、`singleEvents` で返し方が変わります。

- **`singleEvents=false`**（デフォルト）: 繰り返しの親イベント（マスター）と、保存済みの例外（個別に変更された回）を返します。
- **`singleEvents=true`**: 各回のイベントを展開して返します。

### instances エンドポイント

`GET /calendar/v3/calendars/{calendarId}/events/{eventId}/instances` は、単一の繰り返しの系列を展開した各回のインスタンスを常に返します。

- `singleEvents=false` の場合、`showDeleted` を指定しなくてもキャンセル済みの例外が含まれます。
- `events.list` のパラメータに加えて `originalStart` を受け付けます。`timeMin`、`timeMax`、`showDeleted`、`maxResults`、`pageToken`、`timeZone` などが使えます。
- `syncToken` を指定するとエラーになります。
- レスポンスに `nextSyncToken` は含まれません。

### インスタンス ID と例外

各インスタンスには `recurringEventId`、`originalStartTime`、再現可能な ID が付与されます。

- **時刻指定イベント**: UTC の `<seriesId>_<YYYYMMDDTHHMMSSZ>`
- **終日イベント**: `<seriesId>_<YYYYMMDD>`

`/events/{instanceId}` では、各メソッドが単一の回に対して動作します。

| メソッド       | 動作                                                             |
| -------------- | ---------------------------------------------------------------- |
| `GET`          | 例外が保存されていなくても、該当する回のインスタンスを返します。 |
| `PUT`, `PATCH` | その回を例外として保存します。                                   |
| `DELETE`       | キャンセル済みの例外として保存します。                           |

親イベントへの変更は、各回にも影響します。

- 親イベントを更新すると、各インスタンスも更新されます。新しいルールで生成されなくなった保存済みの例外はキャンセルされます。
- 親イベントを削除すると、その例外もすべてキャンセルされます。

展開ロジックは他の機能にも適用されます。

- `singleEvents=true` の場合、[差分同期](https://calendarpit.dev/ja/sync.md)では存在しなくなった回が `cancelled` として返されます。
- `freebusy.query` でも同じ展開ロジックが使われます。

### ルールと検証

`recurrence` には `RRULE`、`EXRULE`、`RDATE`、`EXDATE` の行を 1〜20 行指定できます。空配列を指定すると、繰り返しなしの通常イベントとして保存されます。

次の場合は `400 unsupportedFeature` を返します。

- これ以外の行（`DTSTART` など）
- `FREQ` の欠落
- 日単位未満の `FREQ`（`HOURLY`、`MINUTELY`、`SECONDLY`）
- 下表にない大文字のルール要素（`BYHOUR` など）
- ルール要素の重複

次の場合は `400 invalidParameter` を返します。

- 構文エラー
- 不正な値や組み合わせ、行数の超過

対応しているルール要素:

| ルール要素   | 指定できる値                                                                     |
| ------------ | -------------------------------------------------------------------------------- |
| `FREQ`       | `DAILY`、`WEEKLY`、`MONTHLY`、`YEARLY`                                           |
| `INTERVAL`   | 1〜10000                                                                         |
| `COUNT`      | 1〜10000。`COUNT` と `UNTIL` は同時に指定できません。                            |
| `UNTIL`      | 時刻指定の `UNTIL` は UTC（`...Z`）で指定します。終日の `UNTIL` は日付形式です。 |
| `BYDAY`      | `MO`〜`SU`。`MONTHLY`・`YEARLY` では `±1`〜`5` も指定できます。                  |
| `BYMONTHDAY` | `±1`〜`31`                                                                       |
| `BYMONTH`    | `1`〜`12`                                                                        |
| `BYSETPOS`   | `±1`〜`366`                                                                      |
| `WKST`       | 対応                                                                             |

タイムゾーンに関する要件:

- **時刻指定の系列**: `start.timeZone` が必須です。
- **`RDATE` と `EXDATE`**: 時刻指定の系列では `;TZID=<zone>` を指定できます。終日の系列では `;VALUE=DATE` が必須です。

### 展開の上限

展開には 2 つの上限があります。いずれかを超えると `400 unsupportedFeature` を返します。

- **インスタンス数**: 1 回の展開につき最大 10,000 インスタンスです。リクエストの期間内、または `timeMax` がない場合は系列全体が対象です。
- **期間**: `DTSTART` から最大 100 年（366×100 日）までです。`timeMax` なしで展開する終了条件のある系列と、`timeMin`・`timeMax` の値が対象です。

終了条件のない系列（`COUNT`・`UNTIL` なし）では、次の場合に `timeMax` が必須です。

- 展開する場合（`singleEvents=true`）
- `events.list` に `timeMin` を渡す場合

> [!WARNING]
>
> 1 つでも上限を超える系列があると、一覧取得や `freeBusy` のリクエスト全体が失敗します。削除済みの系列も対象に含まれます。`timeMax` を指定するか、状態をリセットしてください。
>

### 夏時間（DST）

ルールは `start.timeZone` の現地時刻を維持します。

**存在しない現地時刻**（夏時間開始時のスキップ）の場合、展開処理がその回に到達した時点で `400 unsupportedFeature` を返します。対象は次のとおりです。

- `instances`
- `singleEvents=true` または `timeMin`/`timeMax` を指定した `events.list`
- スキップ前後約 2 日以内の回のインスタンス ID 指定
- `freeBusy`

イベントの作成自体は成功します。エラーはリクエスト全体を失敗させます。

**曖昧な現地時刻**（夏時間終了時の重複）には、移行前の早い方のオフセットを使います。たとえばニューヨークでは `-04:00` です。

## 招待、リマインダー、ローカルでの会議データ

Calendarpit が実際にメールを送信したり、Google Meet と通信したりすることはありません。

### 招待

`attendees` には、重複のないメールアドレスを最大 200 件まで指定できます。各参加者には `displayName`、`optional`、`resource`、`additionalGuests`、`comment`、`responseStatus` を指定できます。`responseStatus` は `needsAction`、`accepted`、`tentative`、`declined` のいずれかです。

プライマリカレンダーを持つローカルのテストユーザーを招待すると、そのカレンダーにイベントのコピーが書き込まれます。コピーは同じ `id` と `iCalUID` を持ちます。

- 主催者による更新やキャンセルは各コピーに反映され、[watch 通知](https://calendarpit.dev/ja/watch.md)が発行されます。
- 参加者を削除すると、そのユーザーのコピーはキャンセルされます。
- `reminders` と `extendedProperties.private` は、コピーごとに独立して保持されます。
- `extendedProperties.shared` は主催者のイベントから同期されます。

### 出欠回答（RSVP）と招待された側の編集

招待されたユーザーは `PATCH` を使う必要があります。`PUT` は `403 forbiddenForNonOrganizer` を返します。

招待されたユーザーが編集できるのは次の項目だけです。

- 自身の `responseStatus` と `comment`（または、自身のエントリのみを含めた `attendeesOmitted: true` の設定）
- `reminders`
- `colorId`
- `transparency`
- `extendedProperties`

招待された側の編集による影響:

- 出欠回答の変更は、主催者のイベントと他の参加者のコピーにも反映されます。
- 繰り返しの系列の 1 つのインスタンスに出欠回答すると、主催者のカレンダーに例外が作成されます。
- 招待を辞退（`declined`）すると、招待された側のコピーは `transparent` になります。それ以外の `attendees` の変更では `opaque` に戻ります。
- コピー側で変更した `colorId` と `transparency` は、次回主催者がイベントを更新したときに上書きされます。

### ゲスト権限フラグ

| フラグ                    | デフォルト |
| ------------------------- | ---------- |
| `guestsCanModify`         | `false`    |
| `guestsCanInviteOthers`   | `true`     |
| `guestsCanSeeOtherGuests` | `true`     |

- 真偽値として保存と取得ができます。
- 変更できるのは主催者だけです。
- 値が保存されるだけで、招待されたユーザーが編集できる範囲には影響しません。

### `sendUpdates`

| 値                   | 記録される内容                                                                                                                                            |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `all`                | 書き込みごとに 1 件記録されます。`recipients` は主催者を除く全参加者で、削除された参加者も含みます。招待されたユーザーによる `REPLY` の宛先は主催者です。 |
| `externalOnly`       | ローカルのテストユーザーではない宛先のみが記録されます。                                                                                                  |
| `none`（デフォルト） | 何も記録されません。                                                                                                                                      |

- 記録されたレコード（`REQUEST`、`CANCEL`、`REPLY`）は、`GET /__calendarpit/state` の `notifications` で確認できます。保持されるのは最新 200 件です。[テスト制御 API](https://calendarpit.dev/ja/test-control.md)を参照してください。
- `sendUpdates` の値にかかわらず、ローカルのコピー間の同期は行われます。

### リマインダー

- **形式**: `{ useDefault: boolean, overrides?: [{ method: "email" | "popup", minutes: 0..40320 }] }`
- **上限**: `useDefault: false` の場合、`overrides` は最大 5 件までです。
- **動作**: データは保存されるだけで、リマインダーが通知されることはありません。

### 会議データ（`conferenceData`）

- `null` 以外の `conferenceData` を扱うには、`?conferenceDataVersion=1` の指定が必要です。
- `hangoutsMeet` の `createRequest.requestId` を指定すると、再現可能な ID、`hangoutLink`、`https://meet.calendarpit.invalid/<id>` を指す `video` エントリポイントが生成されます。
- `conferenceData: null` を指定すると、会議データが消去されます。

## 時刻の扱いと FreeBusy

イベントの期間は半開区間 `[start, end)` として扱われます。`end` は `start` より後の時刻である必要があります。

一覧取得のフィルタは `start < timeMax` かつ `end > timeMin` で判定します。そのため、前後に隣接するイベント同士は重複しません。

### 終日イベント

- `start.date` と、終了日（その当日は含まない）を表す `end.date` は `YYYY-MM-DD` 形式です。
- カレンダーのタイムゾーンの午前 0 時を基準にします。
- 夏時間の切り替えで 1 日が 23 時間や 25 時間になる日も同様です。

### 時刻指定イベント

- `dateTime` には UTC オフセット（`Z` を含む）または IANA の `timeZone` が必須です。
- オフセットのない現地時刻が夏時間のスキップ期間または重複期間に該当する場合は `400` を返します。
- レスポンスの `dateTime` は、リクエストで指定した `timeZone`（未指定時はカレンダーのタイムゾーン）の数値オフセット付きでフォーマットされます。
- レスポンスの `timeZone` フィールドも、リクエストで指定したタイムゾーン（またはカレンダーのタイムゾーン）になります。イベントに保存されたタイムゾーンではありません。

### FreeBusy

`POST /calendar/v3/freeBusy` は最大 50 個のカレンダーを照会します。`timeZone`（デフォルトは `UTC`）でフォーマットされた busy 区間を返します。

busy 区間の計算では、次の処理を行います。

- 繰り返しの系列を展開します。
- `transparent`（予定なしとして扱うイベント。辞退した招待を含む）のイベントを除外します。
- `cancelled`（削除済み）のイベントを除外します。
- 重複または隣接する区間を結合します。
- `[timeMin, timeMax)` の範囲に切り詰めます。

存在しないカレンダーや所有していないカレンダーには、カレンダーごとの `notFound` エラーと `busy: []` を返します。
