タイムゾーンを跨ぐ時間コンポーネントの実装ガイド

まずは、Ptengineの時間コンポーネントを見てみましょう。今日を選択すると16日が表示されますが、右上のローカル時間は17日になっています。これは私のローカルタイムゾーンがAsia/Shanghaiで、時間コンポーネントのタイムゾーンを手動でPacific/Honoluluに設定したためです。これがいわゆるタイムゾーンを跨ぐ時間コンポーネントです。
![heatmap-date.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3813491/3da6b83a-ac0a-379e-fac4-d9ce038764bb.png)
そのために、上記のような時間コンポーネントを実現するには、タイムスタンプ、タイムゾーン、および日付がそれぞれ何を意味し、それらの間の関係を理解する必要があります。

### 一、タイムゾーン
タイムゾーンとは、地球の自転によって生じる位置の違いによる時間差のことです。

時間に関する問題を処理するには、UTC（協定世界時）という概念を無視することはできません。協定世界時（UTC）は、原子時計に基づいた全球共通の時間標準です。ただし、UTCは閏秒を挿入するため、国際原子時（TAI）とは異なります。TAIは閏秒を考慮しませんが、UTCは不定期に閏秒を挿入するため、UTCとTAIの時間差は徐々に広がっています。UTCはグリニッジ標準時（GMT）にも近いですが、両者は完全に同じではありません。地球の自転の不規則性や徐々に遅くなることにより、GMTは基本的にUTCに取って代わられています。UTCが標準時間であるため、一般的にはUTC+/-Nの形式でタイムゾーンを表します。たとえば、中国標準時（Asia/Shanghai）は通常UTC+8と表されます。しかし、英国のタイムゾーン（Europe/London）は単純にUTC+Nと表すことはできません。夏時間制度のため、Europe/Londonは夏季にはUTC+1、冬季にはUTCまたはGMTとなります。このため、特定の地域のタイムゾーンは固定されておらず、固定された時差で時間を処理することはできません。理論的にはUTCとGMTは同じですが、閏秒がある場合には微妙な違いがあります。しかし、日常の使用では無視しても問題ありません。
#### • UTC タイムゾーン
時間が協定世界時（UTC）で表される場合、形式は `YYYY-MM-DDTHH:mm:ssZ`です。この場合の `Z`はその時間がUTC時間であることを示します。例えば、09:30 UTC は 09:30Z に相当します。
#### • UTC オフセット
UTCオフセットは、あるタイムゾーンが協定世界時（UTC）よりも何時間進んでいるか、または遅れているかを示す時間差です。±[hh]:[mm] または ±[hh]:[mm] または ±[hh] の形式で表されます。例えば、北京時間のタイムゾーンは +08:00 / +0800 / UTC+8 と表されます。
## 二、タイムスタンプ

タイムスタンプとは、現在の日付から1970年1月1日00:00:00 UTCまでのミリ秒数を指します（特にJavaScriptにおけるタイムスタンプ）。

JavaScriptのDateはタイムゾーン情報を含まなく、Dateオブジェクトが表すのは必ず現在のタイムゾーンです。例えば、現在が中国のタイムゾーンである場合、東京の現在のタイムスタンプを知るにはどうすればよいでしょうか。

まず、以下のように試してみます：
```js
new Date('1970-01-01T00:00:00Z');
// Thu Jan 01 1970 08:00:00 GMT+0800 (中国标准时间)
```
JavaScriptの実行時には、実際には現在のタイムゾーンを知っています。それでは、ローカル時間を他のタイムゾーンの時間に変換するにはどうすればよいでしょうか？Dateオブジェクトの観点からは、直接サポートされていません。つまり、Dateオブジェクトのタイムゾーンを設定することはできません。しかし、次のような方法で「裏技」を使うことができます。Dateオブジェクトの時間に対応する時差を加減することで、Dateオブジェクトが依然としてローカルタイムゾーンにあると考えられている場合でも、他のタイムゾーンの時間を正しく表示することができます。

さらに、`toLocaleString()`メソッドを使用することもできます。このメソッドはタイムゾーンのパラメータを受け取り、指定されたタイムゾーンに従って時間をフォーマットします。

例を見てみましょう：
```jsx
const convertTimeZone =(date,timeZone)=>{
	return new Date(date.toLocaleString('en-Us',{ timeZone }))
}

const now = new Date()//Tue Jun 04 2024 11:35:16 GMT+0800(北京時間)

convertTimeZone(now,'Asia/Tokyo')//Tue Jun 04 2024 12:35:16 GMT+0800(北京時間)
```
`toLocaleString()` メソッドで指定したロケールとタイムゾーンを持つ `Intl.DateTimeFormat` オブジェクトが実際にはJavaScriptの実行時に毎回新しく作成されることがあり、これにはパフォーマンスのコストがかかります。そこで、繰り返し使用するために自分で `DateTimeFormat` を実装することができます：
```jsx
const timeZoneConverter = (timeZone) => {
		// 同じターゲットタイムゾーンで再利用するために新しい DateTimeFormat オブジェクトを作成する
		// タイムゾーンプロパティは DateTimeFormat オブジェクトを作成する際に指定する必要があるため、
		// 同じタイムゾーン用にフォーマッターを再利用するしかありません
    const formatter = new Intl.DateTimeFormat('zh-N', {
        year: 'numeric',
        month: '2-digit',
        day: '2-digit',
        hour: '2-digit',
        minute: '2-digit',
        second: '2-digit',
        hour12: false,
        timeZone
    })
    return {
				// 指定された Date オブジェクトを特定のタイムゾーンに変換する conver メソッドを提供
        convert (date) {
						// `zh-CN` のロケール設定では、`1970/01/01 00:00:00` のような文字列が返されます。
						// 文字列を置換して、`1970-01-01T00:00:00` のような ISO 8601 標準の時間文字列を構築し、正しく解析できるようにします
            return new Date(formatter.format(date).replace(/\//g, '-').replace(' ', 'T').trim())
        }
    }
}

const toLondonTime = timeZoneConverter('Asia/Tokyo') // 同じタイムゾーンに対して、このオブジェクトは再利用可能です。

const now = new Date() // Thu Jun 06 2024 16:13:33 GMT+0800 (北京時間)
toLondonTime.convert(now) // Thu Jun 06 2024 17:13:33 GMT+0800 (北京時間)
```
### 三、日付

一般的に、日付は2024-06-03や2024-05-03のように表されますが、タイムゾーンを跨ぐ場合、別のタイムゾーンでの実際の日付をどのように取得するか、それが普遍的に認識される日付になるかという問題があります。実際には、このような日付はタイムスタンプをフォーマットすることで得られます。

```jsx
export const formatDate = (t: number, format?: string): string => {
	return dayjs(t).format(format || "YYYY-MM-DD")
}
```

それでは、タイムスタンプ、タイムゾーン、および日付の関係が明確になりましたね。他のタイムゾーンの時間を取得する方法として、dayjsのタイムゾーン関連のAPIを使用することもあります。

![Untitled (5).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3813491/74e2d9c1-924c-7661-3fb6-19e57c619aec.png)                                              
                            図1-1

### 四、具体的な実装

Vue3+DayJS+TypeScriptで存在するタイムゾーンの時間コンポーネントを実装するにあたり、DayJSを選択する理由を説明します。

DayJSは軽量なJavaScriptライブラリで、日付と時間を扱うために設計されています。MomentJSの代替として作られており、APIや使い方はほぼ同じですが、ファイルサイズが小さく、MomentJSの約40分の1のサイズである7KBです（MomentJSは280.9KB）。

さらに、MomentJSは将来的にメンテナンスが終了する予定であるため、DayJSの使用が推奨されています。これにより、軽量でモダンなJavaScriptライブラリを利用して、効率的に時間を扱うことができます。

### 1. 正しい各月の日数を計算する

現在の月の正しい日数を計算するには、`dayjs.daysInMonth()` メソッドを使用することができます。このメソッドは引数を受け取らず、Day.js オブジェクトのインスタンスからすべての日付情報を取得します。したがって、特定の月の日数を取得するには、Day.js オブジェクトを作成し、そのオブジェクトの `daysInMonth()` メソッドを呼び出すだけです。これにより、手動で閏年の2月を処理する必要がなくなります。

```jsx
const specificDate = dayjs('2024-06-17');
console.log(specificDate.daysInMonth()); // 30
```

### 2. 当月の1日が何曜日かを計算する

月の日数を取得した後、その月の1日が何曜日かを知る必要があります。このために、`dayjs().day()` を使用して、1日が何曜日から始まるかを調べることができます。このメソッドは、曜日を数値で返しますが、日曜日は0を返しますので、そのままでは正確な値が得られません。そのため、手動で処理を行って正しい値を取得します。

```jsx
const startOfMonth = dayjs('2024-06-17').startOf('month');
const startOfMonthDay = startOfMonth.day() || 7; // 6
```

### 3. データを組み立ててレンダリングする

```jsx
const getRows = () => {
		const startOfMonth = props.date.startOf('month');
    const startOfMonthDay = startOfMonth.day() || 7; // month of first day
    const dateCountOfMonth = startOfMonth.daysInMonth();
    const rows_ = [[], [], [], [], [],[]];
    let count = 1;
		for (let i = 0; i < 6; i++) {
				const row = rows_[i];
        for (let j = 0; j < 7; j++) {
		        if(i===0 && j >=startOfMonthDay ||  (i!==0 && count <= dateCountOfMonth)){
		            let cell = row[j];
                if(!cell) {
		                cell = {
                        row: i,
                        column: j,
                     };
                }
                if (i===0 || i===1) {
		                const numberOfDaysFromPreviousMonth =
		                    startOfMonthDay + offset < 0 ? 7 + startOfMonthDay + offset : startOfMonthDay + offset;
                    if (j + i * 7 >= numberOfDaysFromPreviousMonth) {
		                    cell.text = count++;
                    } 
                } else {
                    if (count <= dateCountOfMonth) {
	                       cell.text = count++;
                    }
                }
                row[j] = cell;
		        }
		    }
		}
    return rows_;
}; 
```

上記のコードを使用して、次のようなデータ構造のデータを取得できます。
![data-structure.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3813491/a61598c9-0c75-9eb0-688c-a6dd8fc9c9e2.png)


次に、`v-for` ループを使用して先ほど取得したデータをページにレンダリングする方法を説明します。`WEEKS` は曜日の文字列を定義し、それをループでレンダリングするだけで良いですね。

```jsx
<tbody>
		<tr>
				<th v-for="(week, key) in WEEKS" :key="key">{{week }}</th>
		</tr>
		<tr
				v-for="(row, key) in rows"
				:key="key"
				class="p-date-table__row"
				:class="{ current: isWeekActive(row[1]) }"
		>
				<td v-for="(cell, key_) in row" :key="key_" :class="getCellClasses(cell)">
						<div>
								<span>
								{{ cell?.text ?? '' }}
								</span>
						</div>
				</td>
		</tr>
</tbody>
```

すべてのステップを完了した後、適切なスタイルを追加すると、次の画像の緑のボックス内のコンテンツが得られます。

![date-result.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3813491/820a345c-70e8-ad71-973c-30486fbd6a92.png)

次に、各日付にクリックイベントをバインドし、自身のDOM要素を渡すことで選択された日付を取得できます。

```jsx
function handleClick(e){
		const startDayOfMonth = props.date.startOf('month'); //获取的日期为上个月最后一天
    const currentDate = startDayOfMonth.add(e.target.value, 'day'); //获取的dom内的内容
   return currentDate;
};
```

ただし、別の問題がありますが、日付はあるものの、今日が何日かわからない（今日の日付に特別なスタイルがありません）。ですので、日付を計算する際に、今日の日付に特別なマークを付けて、レンダリング時に今日に特別なスタイルを付けることができます。

これを実現するために、[**3.データを組み立ててレンダリングする部分**](https://www.notion.so/6e716e2275104422860a343362f2b9c9?pvs=21) の `const rows_ = [[], [], [], [], []];` の後に以下のコードを追加してください。

```jsx
let todayDate = '';
if (props.timeZone) {
		todayDate = dayjs().tz(props.timeZone ?? '').startOf('day').format('DD');
}
```

[**3.データを組み立ててレンダリングする部分**](https://www.notion.so/6e716e2275104422860a343362f2b9c9?pvs=21) 」の`row[j] = cell;` の前に、このコードを追加してください。

```jsx
 cell.text == todayDate && (cell.type = 'today');
```

### 4. タイムゾーンを越えた日付の取得

下図は、今週の時間帯を簡単に選択する機能を示しています。しかし、この記事の冒頭で明示されているように、今日の日付は2024年6月17日です。なぜ下図で選択されている時間帯が2024年6月10日から2024年6月16日なのでしょうか？これは、私が手動で時間コンポーネントをUTC-10のタイムゾーンに設定しているためで、私のローカルタイムゾーンがUTC+8であるため、タイムゾーンを越えた場合に手動で処理する必要があるからです。次に、この問題をどのように処理したかを示します。

![date-example.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3813491/d986b96f-923d-505b-fc36-8c62d9989ae9.png)


### 1. 今日が含まれる週の日付を取得する方法

```jsx
const convertTimeZone =(date,timeZone)=>{
	return new Date(date.toLocaleString('en-Us',{ timeZone }))
}

function getThisWeek(){
		const time = convertTimeZone(new Date(),'Pacific/Honolulu')
		let startDate = dayjs(time).startOf('week') //デフォルトでは、Day.js では日曜日を週の最初の曜日として扱います。
		if(time == startDate.valueOf()){
				//Day.jsではデフォルトで日曜日が週の最初の曜日として扱われますが、一般的な認識では月曜日が週の始まりですね。
				startDate = startDate.subtract(6, 'day')
		}else{
				startDate = startDate.add(1,'day')
		}
		const endDate = startDate.add(6,'day')
		return [startDate,endDate]
}

const shortcuts = () => {
    return [
        {
            text: '今週',
            type: 'today',
            value: getThisWeek()
        },
    ];
};
```

### 2. 時間を取得した後、時間コンポーネントに設定する

```jsx
<pt-date-picker
	:hasHelpText="true"
	v-model="dataTime"
    format="YYYY/MM/DD"
    :shortcuts="shortcuts()"
    timeZone="Pacific/Apia"
    value-format="YYYY/MM/DD"
    type="date"
    placeholder="选择日期"
>
</pt-date-picker>
```

### 3. 取得した時間をコンポーネントに渡し、内部で`v-for`を使用してレンダリングし、クリックイベントをバインドする

```jsx
<div v-if="hasShortcuts" :class="nsDatePick.e('sidebar')">
     <button
        v-for="(shortcut, key) in shortcuts"
        :key="key"
        type="button"
        :class="nsDatePick.e('shortcut')"
        @click="handleShortcutClick(shortcut)"
     >
       {{ shortcut.text }}
     </button>
</div>
```

今日をクリックすると、クリックイベントが発生し、そのイベント内で時間コンポーネントに`shortcuts`内の対応する要素の値を提出します。ただし、この時点ではフォーマットされた時間ではありません。ユーザーが時間を選択した後は、フォーマットされた時間をフィードバックする必要があります。これは、**図1-1**で示されている`format()`メソッドです。

### 五、まとめ

この記事では、まず時区やタイムスタンプなどの基本概念を紹介し、`toLocaleString()` メソッドを使用して特定のタイムゾーンのタイムスタンプを取得し、`format()` メソッドを使用して日付をフォーマットする方法について詳しく説明しました。また、Day.jsの関連APIの使用方法についても詳細に説明し、これらの知識が後続のコンポーネント開発の基礎となるようにしました。その後、日付の計算方法を具体的に示し、コードと実例のデモを通じて説明しました。

### 附録

[こちら](https://codepen.io/siyuan-the-builder/pen/wvbypmO)のリンクは、上記で述べたコアコードを使用してデモを実装し、コードのデバッグをサポートしています。開発環境の違いにより、このコードの一部の構造は本文のデモと若干異なる場合があります。