Temporalで8月32日をサポートする

**Temporal**とは、JavaScriptに組み込まれた新しい日付計算機能です（正確にはまだStage 3ですが、仕様自体はほとんど完成している状態です）。従来あった`Date`のさまざまなつらい部分が解消されたAPIとなっており、実用化が待たれます。

**8月32日**とは、9月1日の到来を拒む一部の人たちが主張する仮想上の日付です。日本では9月1日は夏休みが終わり学校に登校しなければならない日ですが、8月32日ではまだ夏休みが続いているとされています。

残念ながら、日付を扱う多くのアプリケーションでは8月32日がサポートされていません。しかし実は、Temporalでは**Calendar**という仕組みを用いることで8月32日をサポートすることができまです。この記事では、8月32日をサポートするCalendar、すなわち**永遠の夏休みカレンダー**として筆者が実装した[eternal-summer-vacation](https://www.npmjs.com/package/eternal-summer-vacation)の実装や使用例を紹介します。独自のCalendarを作りたい際の参考にしてください。

なお、まだTemporalは本物の実装が使えるわけではないので、このパッケージは公式Polyfillである`@js-temporal/polyfill`を使用しています。

## サンプル

こちらは、Reactで今年のカレンダーを表示するサンプルです。ボタンを押すと通常のカレンダーと永遠の夏休みカレンダーを切り替えることができます。コードを読んでみると、両者は「`Temporal.PlainDate`オブジェクトを作る際にどのCalendarオブジェクトを使うか」という点にのみ影響を与えており、その他の部分は全く同じコードで2種類のカレンダーを描画することができることが分かります（`useCalendar.ts`を参照）。このように、Temporalではカレンダーという概念が抽象化されており、うまくコードを書くことで異なるカレンダーに対応したコードを書くことができます[^note_1]。

[^note_1]: 今回の実装は結構サボっていて、例えば1週間が7日ではないカレンダーだと壊れてしまいます。

https://codesandbox.io/s/eternal-summer-vacation-qctks?file=/src/App.tsx

永遠の夏休みカレンダーモードにすると、次のように8月32日以降があるカレンダーを出力することができます。

![永遠の夏休みカレンダーのスクリーンショット](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233667/3ace046a-f465-37b1-6d5c-9c8dc129bbc4.png)



## Calendarの役割

Temporalでは、日付の内部表現はISO 8601で定められた通り[グレゴリオ暦](https://ja.wikipedia.org/wiki/%E3%82%B0%E3%83%AC%E3%82%B4%E3%83%AA%E3%82%AA%E6%9A%A6)です。Calendarは、その日付を**どう表示するか**を司るというのが基本的な役割です。さらに、付随して「月はいくつあるのか」「この月には何日あるのか」「一週間は何日なのか」といったことをカスタマイズできるようになっています。

今回実装したカレンダーの中心となるのは、例えばISO 8601カレンダーで`2021-09-01`と表現された日付を「2021年8月32日」に変換する、あるいはその逆を行うロジックです。

独自の`Calendar`を作るには、`Temporal.Calendar`を継承するのが簡単です。以下ではコードの一部を抜き出しながら説明します。全体は以下のURLで見ることができます。

- https://github.com/uhyo/eternal-summer-vacation/blob/8f3927d88f2440c33c70dbba6f4dc49086d1df1a/src/eternal-calendar.ts


### 永遠の夏休み日付の計算

ある日付が永遠の夏休みカレンダー上で何月何日になるのか定義するには、Calendarの`month`メソッドや`day`メソッドをオーバーライドします。

```ts
    override month(date: DateLike) {
      return eternalDate(getPlainDateWithISOCalendar(date))[0];
    }
    override day(date: DateLike) {
      return eternalDate(getPlainDateWithISOCalendar(date))[1];
    }
```

実装に見える`eternalDate`が、ISO 8601日付を永遠の夏休み日付に変換する関数です。その前段にある`getPlainDateWithISOCalendar`は、与えられた日付のISO 8601日付を得るための関数です。というのも、ここで与えられる`date`引数は`Temporal.PlainDate`オブジェクト（Temporalで日付を表すオブジェクト）だったり`"2021-09-01"`のような数値だったりあるいは`{ year: 2021, month: 9, day: 1 }`のようなオブジェクトだったりします。これらの扱いが面倒なので関数にまとめています。

```ts
function getPlainDateWithISOCalendar(date: DateLike) {
  if (isTemporalDateLike(date)) {
    const fields = date.getISOFields();
    return new Temporal.PlainDate(
      fields.isoYear,
      fields.isoMonth,
      fields.isoDay
    );
  } else if (typeof date === "string") {
    return Temporal.PlainDate.from(date);
  } else {
    return Temporal.PlainDate.from({ ...date, calendar: "iso8601" });
  }
}
```

この関数では最初のif文の中が注目ポイントです。与えられたオブジェクトが`Temporal.PlainDate`のようなTemporalオブジェクトだった場合は`getISOFields`が使用できます。これを用いてISO 8601カレンダーでの日付を得ることができます。直接`date.month`や`date.day`などとすることはできません。なぜなら、`date`にはすでに別のカレンダーが紐づいていてISO 8601日付が得られないかもしれないからです。

また、このような場面で迂闊に`date`の日付計算を行うと、すでに`date`に永遠の夏休みカレンダーが紐づいているかもしれない関係で簡単に無限ループが発生してしまうという罠があります。それを避けるために与えられた`date`への刺激を最小限にし、`getISOFields`の呼び出しだけにしています。`getISOFields`は内部表現を読み出すだけなので、`date`への刺激が小さいと思われます。

実際の計算を行う`eternalDate`はこのような実装になっています。**ISO 8601で8月以降の日付は全部8月に押し込める**ような実装です。2021年12月31日までを全部8月に押し込め、それを超えると2022年1月1日になります。よって、永遠の夏休みカレンダーは1月〜8月しかないカレンダーとなります。

与えられた日付が永遠の夏休み日付での8月何日に当たるかを調べるには与えられた日付がISO 8601日付で8月1日の何日後か調べる必要がありますが、Temporalではそれを行なってくれる`since`メソッドが用意されているので簡単です。注意点として、この`since`は永遠の夏休みカレンダーではなくISO 8601カレンダー上で実行する必要があります。

```ts
function eternalDate(
  isoDate: Temporal.PlainDate
): [month: number, day: number, isoDate: Temporal.PlainDate] {
  if (isoDate.month < 8) {
    return [isoDate.month, isoDate.day, isoDate];
  }
  const august1st = isoDate.with({ month: 8, day: 1 });
  const daysFromAugust1st =
    isoDate.since(august1st, { largestUnit: "day" }).days + 1;
  return [8, daysFromAugust1st, isoDate];
}
```

### 永遠の夏休み日付からISO 8601日付への変換

逆に、永遠の夏休み日付で「8月50日」などと言われたとき、それがISO 8601日付で何月何日にあたるのか計算できる必要があります。Temporalではあくまで日付の内部表現はISO 8601なので、この計算はとても必要です。

これを実装するにはCelendarの`dateFromFields`をオーバーライドします。`fields`とは、`{ year: 2021, month: 8, day: 50 }`のような形で日付を指し示すオブジェクトです。

```ts
    override dateFromFields(
      fields: Parameters<typeof isoDateFromFields>[0],
      options: Temporal.AssignmentOptions
    ): Temporal.PlainDate {
      const { year, month, day } = isoDateFromFields(fields, options);
      return new Temporal.PlainDate(
        year,
        month,
        day,
        eternalSummerVacationCalendar
      );
    }
```

計算の本体は`isoDateFromFields`ですね。内容は先ほどのちょうど逆の計算になっていて、8月の場合は一旦ISO 8601日付の8月1日から出発して何日後という形で計算します。この辺りも`Temporal`の機能を用いていますので、`Temporal`を用いる日付計算の参考になるかもしれません。

```ts
/**
 * Converts an eternal date to ISO date.
 */
function isoDateFromFields(
  fields: {
    year: number | undefined;
    month: number | undefined;
    day: number | undefined;
  },
  options: Temporal.AssignmentOptions
): {
  year: number;
  month: number;
  day: number;
} {
  let { year = 0, month = 1, day } = fields;
  if (month === 8) {
    day = fields.day ?? 1;
    if ((day < 1 || AugustDays < day) && options.overflow === "reject") {
      throw new RangeError("Invalid Date");
    }
    day = constrain(day, 1, AugustDays);
    const august1st = new Temporal.PlainDate(year, 8, 1);
    const targetDate = august1st.add({ days: day - 1 });
    return {
      year: targetDate.year,
      month: targetDate.month,
      day: targetDate.day,
    };
  }
  if (month > 8) {
    throw new RangeError("Invalid Date");
  }
  return {
    year,
    month,
    day: day ?? 1,
  };
}
```

以上が永遠の夏休みカレンダーの実装の8割くらいを占めています。本当に、ISO 8601による内部表現との変換が主要なタスクであることが分かります。

他にやっていることとして、[日付の足し算の処理](https://github.com/uhyo/eternal-summer-vacation/blob/8f3927d88f2440c33c70dbba6f4dc49086d1df1a/src/eternal-calendar.ts#L73)や[月の中に何日あるかというクエリへの対応](https://github.com/uhyo/eternal-summer-vacation/blob/8f3927d88f2440c33c70dbba6f4dc49086d1df1a/src/eternal-calendar.ts#L65)があります。

## 永遠の夏休み日付の利用

永遠の夏休み日付からTemporalオブジェクトを作るには次のようにします。

```ts
const august32 = Temporal.PlainDate.from({
  year: 2021,
  month: 8,
  day: 32,
  calendar: eternalSummerVacationCalendar
});

console.log(august32.month, august32.day); // 8 32

console.log(august32.toJSON()); // "2021-09-01[u-ca=eternal-summer-vacation]"
```

注意しなければいけない点として、このように作った`Temporal.PlainDate`オブジェクトは`month`や`day`といったプロパティで永遠の夏休み日付を取得することができますが、`toJSON`などで文字列化しても`2021-08-32`のような表記は得られません。

これは、繰り返しになりますが、日付の内部表現はあくまでISO 8601であり、シリアライゼーションなど相互運用性が必要な場面ではISO 8601表現が使われるからです。Calendarは「日付データが時間のどの時点を指すのか」といったことに関与するものではなく、あくまで表示のためのものであると理解しましょう。

## まとめ

この記事では、8月32日という題材を通してTemporalのCalendarの実装と実用例について学びました。Calendarの実装においては、ISO 8601で表される内部表現と、独自カレンダー上の日付の相互変換が主なタスクであることが分かりました。

これで、通常と異なる時空間に迷い込んでしまった場合でも問題なく日付を扱うことができますね。

サンプル（再掲）:

https://codesandbox.io/s/eternal-summer-vacation-qctks?file=/src/App.tsx

GitHubリポジトリ（宣伝）:

https://github.com/uhyo/eternal-summer-vacation

### Q & A

**Q.** この実装だと永遠ではないのでは？
**A.** 本当に永遠の夏休みは読者の練習問題とします。年がISO 8601と一致しなくなったりするのでさらに難易度が高くおすすめです。