🐷

rrule.jsのタイムゾーンでハマった

2023/12/21に公開

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 関数が相当。
https://github.com/jkbrzt/rrule#timezone-support

実行環境が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();
}

参考

https://anizozina.hateblo.jp/entry/2023/05/14/174642
https://zenn.dev/musou1500/articles/b4d93a21bc1900

Discussion