背景
前回の記事 で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.000Z
か 2023-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の差を使ってあげれば良い。