ねぎ嫌い

始業前に学んだことを小出しに。最近はHacker Newsの人気記事をまとめてみたり。

Google Calendarの繰り返し予定を理解した

背景

Google Calendarと相互にデータの送受信を行うアプリケーションを実装しており、繰り返し予定の扱いが非常に難解だった。
パッと言葉だけ見てもあんまり理解できなかったので動かしながらメモした中身を残しておこうと思う。

前提

Google Calendarからのデータ取得は、通知チャンネルで受け取ったtokenからevents.listを叩いている。

ref. リソース変更の通知  |  Google Drive  |  Google Developers
ref. Events: list  |  Google Calendar  |  Google Developers

Google Calendarの繰り返し予定は、rruleという仕様に則っている。
rruleの仕様については以下のブログが非常に詳しい。

ref. RRULEに関するメモ書き - 前人未踏の領域へ WEB・インフラ・プログラミング全般編

お断り

データ構造っぽいものに整理しているが便宜上のものであることに留意。
Google Calendar側のデータ構造は全く分からない。

シナリオと発行されるイベント

繰り返し予定の作成

概念図

作成されるイベントの構造(不要な属性は削除)

{
  "kind": "calendar#event",
  "id": "3d6judg0t6ureeq1b4o8inkfd1",
  "status": "confirmed",
  "summary": "テスト(終了日付指定)",
  "start": {
      "dateTime": "2023-05-13T03:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "end": {
      "dateTime": "2023-05-13T04:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "recurrence": [
      "RRULE:FREQ=WEEKLY;WKST=SU;UNTIL=20230701T145959Z;BYDAY=SA"
  ],
  "sequence": 0,
  "eventType": "default"
}

具体的な予定日の情報は含まれていないが、内部的には図のような構造をとっているように見える。
後述する通り、特定の予定の変更をする時は、ここで入ってくるidにsuffixを付与したものがidとして割り当てられている。

e.g. id = 3d6judg0t6ureeq1b4o8inkfd1_20230513T030000Z

ここでは2023-07-01まで毎週土曜日を繰り返し条件に指定している。

特定予約の変更

例えば、2023年4月から毎週金曜日の10時から会議の予定を抑えていたけど、4月28日は連休前なので1時間早めたいケース。

概念図

作成されるイベントの構造(不要な属性は削除)

{
  "id": "3d6judg0t6ureeq1b4o8inkfd1_20230516T200000Z",
  "summary": "テスト(変更用)",
  "start": {
      "dateTime": "2023-05-17T06:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "end": {
      "dateTime": "2023-05-17T07:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "recurringEventId": "3d6judg0t6ureeq1b4o8inkfd1",
  "originalStartTime": {
      "dateTime": "2023-05-17T05:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "sequence": 1,
  "eventType": "default"
}

前述した通り、idは元の繰り返し予定のIDにsuffixがつく形式になっている。
見えている通り、UTCをsuffixとして生成している。

変更前後が観測できることもわかる。

特定日付の予定の削除

例えば、2023年4月から毎週金曜日の10時から会議の予定を抑えていたけど、5月5日が子供の日なのでカレンダーから削除するようなケース。

概念図

作成されるイベントの構造(不要な属性は削除)

{
  "kind": "calendar#event",
  "id": "3d6judg0t6ureeq1b4o8inkfd1_20230516T190000Z",
  "status": "cancelled",
  "recurringEventId": "3d6judg0t6ureeq1b4o8inkfd1",
  "originalStartTime": {
      "dateTime": "2023-05-17T04:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  }
}

特定日付の変更と同じように、suffixのついたIDに対して削除処理が走るのが見て取れる

繰り返し予定の削除

概念図

作成されるイベントの構造(不要な属性は削除)

{
  "kind": "calendar#event",
  "etag": "\"3367880984644000\"",
  "id": "3d6judg0t6ureeq1b4o8inkfd1",
  "status": "cancelled"
}

繰り返し予定のIDのみを指定して削除が走る
おそらく内部的には同一のprefixを持つ実イベントに対する削除も走ってるんじゃないかと想定。

日付以降の予定の変更

例えば、2023年4月から毎週金曜日の10時から会議の予定を抑えていたけど、5月から11時に変更するようなユースケース

概念図

作成されるイベントの構造(不要な属性は削除)
1.繰り返し条件の短縮

{
  "id": "3d6judg0t6ureeq1b4o8inkfd1",
  "status": "confirmed",
  "summary": "テスト(変更用)",
  "start": {
      "dateTime": "2023-05-15T05:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "end": {
      "dateTime": "2023-05-15T06:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "recurrence": [
      "RRULE:FREQ=WEEKLY;WKST=SU;UNTIL=20230528T145959Z;BYDAY=SA"
  ],
  "sequence": 0,
  "eventType": "default"
}

見えている通り、繰り返し条件が2023-05-29までの毎週土曜日になっている。
元は前述の通り、07-01まで。
ここで重要なのはidが変更されていないこと。

2.繰り返し条件の作成

{
  "id": "rfr872ajjahljsp9807hmbrt82",
  "status": "confirmed",
  "summary": "テスト(変更用)",
  "start": {
      "dateTime": "2023-05-29T06:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "end": {
      "dateTime": "2023-05-29T07:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "recurrence": [
      "RRULE:FREQ=WEEKLY;WKST=SU;COUNT=3;BYDAY=SA"
  ],
  "sequence": 1,
  "eventType": "default"
}

startTimeに変更を発生させた日時が入り、3回分の予定について作成されている
(UNTILじゃなくてCOUNTなんだーとは思う。)
ここで id が採番され全く別のイベントとして扱われるようになっていることに留意。

つまり、元の繰り返し予約(ID=3d6judg0t6ureeq1b4o8inkfd1) を繰り返し条件ごと予定から削除してもこっちは残るということ。

特定予約変更後、すべての繰り返し条件変更

例えば、2023年4月から毎週金曜日の10時から会議の予定を抑えていたけど、予定の重複があり、4月14日は9時にした。
そのあとで、メンバの都合が合わなくなったから、繰り返し条件を全て11時に変更するようなユースケース
あんまりやってほしくないけど現実的にはありうる。

概念図

作成されるイベントの構造(不要な属性は削除)
1.繰り返し条件の変更

{
  "id": "3d6judg0t6ureeq1b4o8inkfd1",
  "status": "confirmed",
  "summary": "テスト(変更用)",
  "start": {
      "dateTime": "2023-05-15T07:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "end": {
      "dateTime": "2023-05-15T08:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "recurrence": [
      "RRULE:FREQ=WEEKLY;WKST=SU;UNTIL=20230528T145959Z;BYDAY=SA"
  ],
  "sequence": 1,
  "eventType": "default"
}

ここは特に違和感なく、rruleの条件変更が行われる。
idはそのまま引き継いでいる。

2.変更した特定日付の予定の削除

{
  "kind": "calendar#event",
  "etag": "\"3367885310606000\"",
  "id": "3d6judg0t6ureeq1b4o8inkfd1_20230516T200000Z",
  "status": "cancelled"
}

おそらくGoogleの内部では変更された特定日付のデータは独立したものとして扱われているのだと思われる。

特定日付の予定を変更後、別日以降の繰り返し条件の変更

例えば、2023年4月から毎週金曜日の10時から会議の予定を抑えていたけど、予定の重複があり、4月14日は9時にした。
そのあとで、メンバの都合が合わなくなったから、5月からの繰り返し条件を全て11時に変更するようなユースケース

概念図

作成されるイベントの構造(不要な属性は削除)
1.繰り返し条件の変更

{
  "id": "3d6judg0t6ureeq1b4o8inkfd1",
  "status": "confirmed",
  "summary": "テスト(変更用)",
  "start": {
      "dateTime": "2023-05-15T05:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "end": {
      "dateTime": "2023-05-15T06:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "recurrence": [
      "RRULE:FREQ=WEEKLY;WKST=SU;UNTIL=20230528T145959Z;BYDAY=SA"
  ],
  "sequence": 0,
  "eventType": "default"
}

2.変更した予定の修正

{
  "id": "3d6judg0t6ureeq1b4o8inkfd1_20230516T200000Z",
  "status": "confirmed",
  "summary": "テスト(変更用)",
  "start": {
      "dateTime": "2023-05-17T06:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "end": {
      "dateTime": "2023-05-17T07:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "recurringEventId": "3d6judg0t6ureeq1b4o8inkfd1",
  "originalStartTime": {
      "dateTime": "2023-05-17T05:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "sequence": 1,
  "eventType": "default"
}

3.以降の繰り返し条件の作成

{
  "id": "rfr872ajjahljsp9807hmbrt82",
  "status": "confirmed",
  "summary": "テスト(変更用)",
  "start": {
      "dateTime": "2023-05-29T06:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "end": {
      "dateTime": "2023-05-29T07:00:00+09:00",
      "timeZone": "Asia/Tokyo"
  },
  "recurrence": [
      "RRULE:FREQ=WEEKLY;WKST=SU;COUNT=3;BYDAY=FR,MO,TH,TU,WE"
  ],
  "sequence": 1,
  "eventType": "default"
}

一番複雑なケースだと思われる。
ただ、ここまで見てればなんとなく言いたいことはわかる。

凡例

終わりに

Googleカレンダーの知らなかった仕様なんかも見られたのでなかなか面白かった。
ここで発生しているデータをどういう構造で扱うべきかまだ見えてないけど。

rrule.jsでJSTを扱うのに困ったのでこれも記事にしておきたい。