Timezone変換ツールを作った時の備忘録

こんにちは。

先日<a href="https://robopost.app/ja/free-tools/utc-timezone-converter">Timezone変換ツール</a>を実装したので、ライブラリ選定や実装内容などの記録を残しておこうと思います。


# ツールで実現したいこと
- 入力したUTC時間(HH:MM)を、選択したタイムゾーン時間に変換する
- 入力したサマータイム（DST）時間(HH:MM)を、選択したタイムゾーン時間に変換する
- PCのタイムゾーン設定を取得する

UIはこんな感じです。
![timezone_converter.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166409/a79d6ed9-cdb3-7b30-bbec-73ce848dca4f.png)



# 使用したライブラリ
- React (18.2.0)
- countries-and-timezones (^3.6.0)
- date-fns (^2.30.0)


# 実装
### 1. Timezone一覧を取得する
#### ライブラリ選定
まずは全Timezone情報の取得から。
自前でデータ保存するのは管理コストがかかるので、手っ取り早くライブラリを使っちゃおうと思い、npmでちょうどいいパッケージがないか探しました。

今回のツールでは「サマータイム期間と、そうでない期間における時差を確認できる」ことを実現したかった（のちに詳しく解説）ので、
- UTCとDSTのOffset情報が取得できる
- パッケージの最終更新日時が比較的新しい
- ダウンロード実績がそこそこある

という理由で<a href="https://www.npmjs.com/package/countries-and-timezones">countries-and-timezones</a>を選びました。

#### インストール
package.jsonに残しておきたいので`--save`を入れてインストール実行します。
```
npm install --save countries-and-timezones
```

#### Timezone一覧を取得
非同期で`getAllTimezones`を実行し、Timezoneの一覧を取得してきます。
```typescript:index.tsx
import ctAndTz from "countries-and-timezones";

export default function Sample() {
  const getAllTimeZone = async () => {
    return await ctAndTz.getAllTimezones();
  };
  
  useAsyncEffect(async() => {
    try {
      const response = await getAllTimeZone();
    } catch (error) {
      console.error('Error getting timezone list:', error);
    }
  }, []);
}
```
全Timezone情報がオブジェクトで返されるので、各Timezone名を抜き出し、リストに詰めてStateに管理します。Timezone名以外にもoffsetなど後で必要になるデータを含んでいるので、`key:Timezone名, value:Timezoneオブジェクト`のMapに変換してこちらも管理しておきましょう。
```typescript:index.tsx

const [timezoneList, setTimezoneList] = useState<string[]>([]);
const [timezoneMap, setTimezoneMap] = useState<Map<string, ctAndTz.Timezone>>();

useAsyncEffect(async() => {
  try {
    const tzList:string[] = [];
    const tzMap = new Map<string, ctAndTz.Timezone>();
    
    const response = await getAllTimeZone();
    Object.values(response).map((obj) => {
      tzList.push(obj.name); // 例: 'Asia/Tokyo'
      tzMap.set(obj.name, obj);
    });
    
    setTimezoneList(tzList);
    setTimezoneMap(tzMap);
    
  } catch (error) {
    console.error('Error getting timezone list:', error);
  }
}, []);
```


### 2. 入力したUTC/夏時間を、選択したタイムゾーン時間に変換する
Timezoneリストが取得できたので、次に時差計算の実装をしていきましょう。

「入力したUTC/夏時間に、選択したTimezoneのoffsetを加算する」という方式で時差を算出していきます。

```typescript:index.tsx
import { parseISO, addMinutes, subMinutes } from 'date-fns'

// 現在時刻をUTCで取得 例: 日本時間 2024年9月6日13時00分 => '2024-09-06T04:00:00.000Z'
const baseISO = new Date().toISOString();
// UTC時間の入力値（初期値は現在のUTC時間）
const [utcTimeVal, setUtcTimeVal] = useState(baseISO.slice(11, 16));
// 選択したTimezone時間の入力値
const [locationTimeVal, setLocationTimeVal] = useState<string>("");

// UTC時間の値が変更されたら、時差計算処理を呼び出す
useEffect(() => {
    calcLocationTime();
  }, [utcTimeVal]);

const calcLocationTime = () => {
    // 置換対象の'HH:MM:SS.fff'を切り取り
    const replaceTarget = baseISO.slice(11, 23);
    // 計算元となる時間軸がUTCならばutcOffset、夏時間ならdstOffsetを選択
    const addOffset = timeZoneType === "UTC" ? tz.utcOffset : tz.dstOffset;
    // 入力されたUTC時間をDateに変換
    const utcDate = parseISO(baseISO.replace(replaceTarget, utcTimeVal + ":00.000");
    // offsetの分だけ時間を進める
    const added = addMinutes(utcDate, addOffset);
    // Date型を文字列に変換後、'HH:MM'の部分を切り出してTimezone時間にセット
    setLocationTimeVal(added.toISOString().slice(11, 16));
}
```
UTC時間の値の変更を検知してこの時差計算処理を呼び出すようにすれば、時差計算ツールの出来上がりです。

タイムゾーンを`Europe/Paris`に設定した場合:
![timezone_converter_002.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166409/d30c2b51-e21f-6e22-abec-896d9e588581.png)
UTC→夏時間に変更:
![timezone_converter_003.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166409/d6ddfc9d-c88c-8ce3-0425-f39fa9ebd735.png)

無理矢理感はありますが、日付を考慮せずUTC/夏時間との時差計算をしたかったので、この実装に落ち着きました。


### 3. PCのTimezone設定を取得
画面ロード時に位置情報を取得してデフォルト値としてセットするのもありかなと思いましたが、その場合は位置情報取得許可を確認するダイアログを実装する必要があります。ツールを利用するまでの手間を極力省きたかったので、今回はボタンを設置し、ボタンクリックで位置情報を取得→計算対象のTimezoneにセットするという実装にしました。

ローカルのTimezone設定は`Intl.DateTimeFormat().resolvedOptions().timeZone`で取得できます。
```typescript:index.tsx
const [targetLocation, setTargetLocation] = useState<string>();

const onClickLocation = () => {
  setTargetLocation(Intl.DateTimeFormat().resolvedOptions().timeZone);
};


return (
  <button type="button" onClick={(e) => {onClickLocation()}}>
    <FontAwesomeIcon icon={faStreetView} />
  </button>
);
```


## まとめ
というわけで、Timezone変換ツールのライブラリ選定基準や実装方法などをざっくりとまとめてみました。
今後は日時単位での時差変換や結果の共有機能などを追加する予定なので、その際はまた備忘録としてまとめたいなと思います。