🐷
rrule.jsのタイムゾーンでハマった
rrule.jsのタイムゾーンでハマった。
たとえば、「毎週火曜日、日本時間8時」のようなことをするために
const rrule = new RRule({
freq: RRule.WEEKLY,
dtstart: new Date('2023-12-19T08:00:00+09:00'),
byweekday: [RRule.TU],
});
const dates = rrule.between(new Date('2023-12-01T00:00:00+09:00'), new Date('2024-02-01T00:00:00+09:00'));
expect(dates).eq([
new Date('2023-12-19T08:00:00+09:00'),
new Date('2023-12-26T08:00:00+09:00'),
new Date('2024-01-02T08:00:00+09:00'),
new Date('2024-01-09T08:00:00+09:00'),
new Date('2024-01-16T08:00:00+09:00'),
new Date('2024-01-23T08:00:00+09:00'),
new Date('2024-01-30T08:00:00+09:00'),
]);
上記だと以下が返ってくる。
Array [
- 2023-12-18T23:00:00.000Z,
- 2023-12-25T23:00:00.000Z,
- 2024-01-01T23:00:00.000Z,
- 2024-01-08T23:00:00.000Z,
- 2024-01-15T23:00:00.000Z,
- 2024-01-22T23:00:00.000Z,
- 2024-01-29T23:00:00.000Z,
+ 2023-12-19T23:00:00.000Z,
+ 2023-12-26T23:00:00.000Z,
+ 2024-01-02T23:00:00.000Z,
+ 2024-01-09T23:00:00.000Z,
+ 2024-01-16T23:00:00.000Z,
+ 2024-01-23T23:00:00.000Z,
+ 2024-01-30T23:00:00.000Z,
]
1日分ずれている。
これが10時(UTCでも同じ火曜日)なら想定通りになる。
const rrule = new RRule({
freq: RRule.WEEKLY,
dtstart: new Date('2023-12-19T08:00:00+09:00'),
byweekday: [RRule.TU],
tzid: 'Asia/Tokyo'
});
として tzidをつけると
Array [
- 2023-12-18T23:00:00.000Z,
- 2023-12-25T23:00:00.000Z,
- 2024-01-01T23:00:00.000Z,
- 2024-01-08T23:00:00.000Z,
- 2024-01-15T23:00:00.000Z,
- 2024-01-22T23:00:00.000Z,
- 2024-01-29T23:00:00.000Z,
+ 2023-12-19T14:00:00.000Z,
+ 2023-12-26T14:00:00.000Z,
+ 2024-01-02T14:00:00.000Z,
+ 2024-01-09T14:00:00.000Z,
+ 2024-01-16T14:00:00.000Z,
+ 2024-01-23T14:00:00.000Z,
+ 2024-01-30T14:00:00.000Z,
]
こう。
結論としては
const rrule = new RRule({
freq: RRule.WEEKLY,
dtstart: new Date('2023-12-19 08:00:00'),
byweekday: [RRule.TU],
tzid: 'Asia/Tokyo'
});
とすると期待通りになる。
dtstartはUTCというかタイムゾーンなしのような値(2023-12-18T23:00:00.000Zではない)を与えないといけない。
公式ちゃんと読めば書いてあって、
公式の datetime
関数が相当。
実行環境がUTCではない場合は返ってきた値の取り扱いにも注意が必要。
Dateで返ってくるが、タイムゾーンなしとみなさないといけない。
Asia/Tokyoな環境であれば公式記載のように以下で変換する。
const dates = rrule.between(new Date('2023-12-01T00:00:00+09:00'), new Date('2024-02-01T00:00:00+09:00'))
dates.map(date => dayjs(date).utc().tz('Asia/Tokyo', true).toDate())
最終的にラッパーを実装してこんな感じにした。
import type {Options} from 'rrule';
import {RRuleSet, RRule, datetime} from 'rrule';
import dayjs from 'dayjs';
import utcPlugin from 'dayjs/plugin/utc';
import timezonePlugin from 'dayjs/plugin/timezone';
dayjs.extend(utcPlugin);
dayjs.extend(timezonePlugin);
export type RRuleSetExOptions = {
localeTimezone?: string;
};
/**
* RRuleのタイムゾーンに関する取り回しを内部で実行するラッパー
*/
export class RRuleSetEx {
readonly #rruleSet: RRuleSet;
readonly #localeTimezone: string;
constructor(
args: {rruleOpts: Partial<Omit<Options, 'tzid'>>[]; exdates: Date[]; tzid: string},
options?: RRuleSetExOptions
) {
this.#localeTimezone = options?.localeTimezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
const {rruleOpts, exdates, tzid} = args;
this.#rruleSet = new RRuleSet();
for (const rruleOpt of rruleOpts) {
const rrule = new RRule({
...rruleOpt,
dtstart: rruleOpt.dtstart ? convertOptionDate(rruleOpt.dtstart, tzid) : undefined,
until: rruleOpt.until ? convertOptionDate(rruleOpt.until, tzid) : undefined,
tzid,
});
this.#rruleSet.rrule(rrule);
}
for (const exdate of exdates) {
this.#rruleSet.exdate(convertOptionDate(exdate, tzid));
}
}
get rruleSet() {
return this.#rruleSet;
}
get localeTimezone() {
return this.#localeTimezone;
}
between(after: Date, before: Date, inc?: boolean, iterator?: (d: Date, len: number) => boolean): Date[] {
const wrappedIterator = iterator
? (d: Date, len: number) => {
const converted = convertGotDate(d, this.#localeTimezone);
return iterator(converted, len);
}
: undefined;
return this.#rruleSet
.between(
convertOptionDate(after, this.#localeTimezone),
convertOptionDate(before, this.#localeTimezone),
inc,
wrappedIterator
)
.map(d => convertGotDate(d, this.#localeTimezone));
}
all(iterator?: (d: Date, len: number) => boolean) {
const wrappedIterator = iterator
? (d: Date, len: number) => {
const converted = convertGotDate(d, this.#localeTimezone);
return iterator(converted, len);
}
: undefined;
return this.#rruleSet.all(wrappedIterator).map(d => convertGotDate(d, this.#localeTimezone));
}
toString() {
return this.#rruleSet.toString();
}
}
function convertOptionDate(d: Date, timezone: string) {
const td = dayjs(d).tz(timezone);
return datetime(td.year(), td.month() + 1, td.date(), td.hour(), td.minute(), td.second());
}
function convertGotDate(d: Date, timezone: string) {
return dayjs(d).utc().tz(timezone, true).toDate();
}
参考
Discussion