ねぎ嫌い

思いついたことをてきとうに。

rrule.jsでJSTを扱いたい!!

背景

前回の記事Google Calendarの繰り返し予定の扱いについて書いたが、これを解釈してDBに突っ込む必要があった。
rruleはRFCで定められた仕様なのでライセンスが許せば、OSSのライブラリを活用できるはずで、実際実装してくれている有志がいる。
ref. GitHub - jakubroztocil/rrule: JavaScript library for working with recurrence rules for calendar dates as defined in the iCalendar RFC and more.

今回の困りごととしては、曜日の指定で、若干1日ズレるような挙動に遭遇した。
e.g. 毎週金曜日、の指定をしているはずなのに、結果を見てみると毎週土曜日になっているような挙動。

結論から言うと、Timezoneの扱いを考慮してあげる必要があった。
いくつかのIssueで言及されている通りTimezoneの扱いが直感的ではなく苦しんだので、回避策を紹介しておく。

TL;DR;

rrule.jsは内部的にUTCですべての日付を扱うので、日本時間の金曜日として処理しているつもりが、UTCの金曜日として扱われズレた。
内部的な処理としてはUTCとして実施し、データを利用する際にJSTに無理やり変換をすれば良い。

ただ、綺麗な回避策ではないので、他に案があればお伺いしたい所存...。

課題の詳細

そもそも発端となったGoogle Calendarからのインプットを見てみると、扱いたいデータは以下のようになっている。

{
  "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=20230601T145959Z;BYDAY=SA"
  ]
}

これは2023-05-13の午前3時から4時までの予定を2023-06-01までの毎週土曜日に作成する条件になっている。
rrule.jsにこのrruleを食わせ、実際に予定として作成されるであろう日付を取得してみる。

import { rrulestr } from 'rrule';

const rrule = 'RRULE:FREQ=WEEKLY;WKST=SU;UNTIL=20230601T145959Z;BYDAY=SA';
const dtstart = new Date('2023-05-13T03:00:00+09:00');

const rule = rrulestr(rrule, {
  dtstart: dtstart,
});
// 余談だが、`every week on Saturday until June 1, 2023` と出力してくれるので理解促進に役立つ
console.log(rule.toText());

console.log(rule.all())

結果としては、以下の出力が得られる

[
  2023-05-13T18:00:00.000Z,
  2023-05-20T18:00:00.000Z,
  2023-05-27T18:00:00.000Z
]

JSTに直すと、05-14(日)の午前3時からの予定になってしまうのがわかる。
本来欲しい出力の先頭は 2023-05-12T18:00:00.000Z2023-05-13T03:00:00.000+09:00 のいずれか。

このままだと意図しない挙動(=バグ)になってしまう…。

回避策

以下のような関数を作ってtimezoneのoffsetをごにょごにょしてあげることで回避した。

export const rruleToDates = (rrule: string, dtstart: string) => {
  const { date, offset } = trimOffset(dtstart);

  const events = rrulestr(rrule, {
    dtstart: date,
  });
  const convert = (d: Date) => new Date(d.getTime() + offset * 1000 * 60);
  return events.all().map(convert)
};

const trimOffset = (dateStr: string) => {
  const origin = new Date(dateStr);
  const offset = origin.getTimezoneOffset();
  const date = new Date(origin.getTime() + offset * -1 * 1000 * 60);
  return { date, offset };
};

テストコードの一部

    it('2023-05-20から毎週土曜日を3回分', () => {
      const rule = 'RRULE:FREQ=WEEKLY;COUNT=3;BYDAY=SA';

      const startDate = '2023-05-20T18:00:00+09:00';
      const { dates: result } = rruleToDates(rule, startDate);
      expect(result).toHaveLength(3);
      expect(result[0].getTime()).toEqual(
        new Date('2023-05-20T18:00:00+09:00').getTime(),
      );
      expect(result[1].getTime()).toEqual(
        new Date('2023-05-27T18:00:00+09:00').getTime(),
      );
      expect(result[2].getTime()).toEqual(
        new Date('2023-06-03T18:00:00+09:00').getTime(),
      );
    });

懸念事項

一応このままでもほとんどのユースケースは動く。
あと考慮しないといけないのは、以下らへんかなと。

  • UNTIL/COUNTのいずれもない、無期限の予定に対するハンドリング
  • 終了日付の生成

前者に関しては、アプリの特性を考慮した上で、件数か日付での制約をall()の第一引数で実装してあげれば良さそう。
ref. https://github.com/jakubroztocil/rrule#rruleprototypealliterator

後者はGoogle Calendarから入ってくるstartDateTimeとendDateTimeの差を使ってあげれば良い。