タイムゾーンと夏時間: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。ローカル時刻が現れるのは二箇所だけです。入力された瞬間と、描画される一瞬。

← 記事一覧に戻る

コメント

…