タイムゾーンと夏時間:UTC で保存しないと必ず壊れる
夏時間の日は 23 時間か 25 時間で、切り替え付近のローカル時刻は存在しないか二度現れます。UTC で保存し表示時に変換するのが唯一安定する模型です。
ローカル時刻の文字列を保存していると、夏時間の切り替え日には必ず壊れます。ローカル時刻と瞬間の一対一対応が崩れるからです。
切り替え日の二つの異常
春の進み:ローカル時計が 02:00 から 03:00 へ飛ぶため、02:30 は存在しません。
秋の戻り:時計が 03:00 から 02:00 へ戻るため、02:30 は一時間違いで二度現れます。
"2026-03-08 02:30" を見ても、どの瞬間か答えられません。存在しないか、二つのうちのどちらかです。
正しい模型
| 保存するもの | 変換する時 |
|---|---|
| UTC のタイムスタンプ(またはオフセット付き ISO 8601) | 表示する時 |
ユーザーのゾーン識別子(Asia/Tokyo) |
ローカルな意味が要る時 |
// 保存
const instant = Date.now(); // UTC ミリ秒
const iso = new Date().toISOString(); // Z 付き
// 表示(固定オフセットではなく IANA ゾーン)
new Intl.DateTimeFormat('ja-JP', {
timeZone: 'Asia/Tokyo',
dateStyle: 'full',
timeStyle: 'short',
}).format(instant);
timeZone は IANA 名で指定します。+09:00 のような固定オフセットは、夏時間のある地域で一時間ずれます。
切り替え日の定期実行
「毎日 02:30 に実行」は春の進みの日に 02:30 がありません。約束は二通りです。
- 飛ばす:その日は実行しない(多くのスケジューラの既定)
- ずらす:03:00 に実行する
どちらかを明示し、文書に書く必要があります。決めなければ「ある日実行されない」か「ある日二回走る」が起きます。
日跨ぎの計算に引き算を使わない
「一日差」は (t2 - t1) / 86400000 を丸めた値ではありません。夏時間の日は 23 時間です。暦で比べます。対象ゾーンで日付を取り出し、年月日を比較します。
const dayIn = (tz: string, t: number) =>
new Intl.DateTimeFormat('en-CA', { timeZone: tz }).format(t);
dayIn('Asia/Tokyo', a) !== dayIn('Asia/Tokyo', b);
オフセットではなくゾーン名を保存する
ユーザーが引っ越した後、過去の出来事に記録されたオフセットは永久に誤りです。Asia/Tokyo を保存すれば、今後規則が変わっても(政府が夏時間を変更するのはよくあることです)自動的に追随します。
内部は常に UTC。ローカル時刻が現れるのは二箇所だけです。入力された瞬間と、描画される一瞬。

コメント
…