Randy Apps


Convenient Apps changing tomorrow.

【JavaScript】タイムゾーン計算 #2:サマータイム境界での誤判定を検証する


前回の記事では、JavaScriptの標準機能(DateIntl)を使ってUTCを逆算する際、実行環境のタイムゾーンに影響されてしまう問題について触れました。

今回は、私が検証の際に直面した「1時間ずれてしまう不具合」の実例と、標準機能だけでそれを解決しようと試行錯誤し、最終的に「標準機能での解決を素直に諦めた理由」を共有します。

以下、日本で開発しているという前提になります。

実行環境の時差が引き起こす不具合の具体例

多くの場合は問題なく正常な時刻を取得できますが、サマータイムの切り替えのタイミングと近いタイミングで問題が発生します。

問題の根本的な原因は、第1回の冒頭でも触れた、文字列から new Date('時刻') を生成した際、ブラウザがそれを「実行環境(今いる場所)の時刻」として解釈してしまうことにあります。

これにより、ターゲット都市のサマータイム切り替え時刻を意図せずまたいでしまった時、本来とは異なるオフセット(時差)を取得してしまう現象が起きます。代表的な2つのケースを、内部の計算プロセスと共に見てみましょう。

ケース1:ニューヨーク(時差が大きい場合の大幅なずれ)

ニューヨーク(America/New_York)の2026年の夏時間は、3月8日(日)の午前2:00に始まり、時計の針が1時間進んで午前3:00になります。

ここでは、夏時間開始後の「3月8日 午前5:00」の時刻をUTCに逆算してみます。本来であれば、夏時間のオフセット「-04:00」が取得されるべきです。

【不具合が発生するコード(ニューヨーク編)】

const targetTimeStr = '2026-03-08T05:00:00'; // 取得したい現地時刻(夏時間)
const targetTimeZone = 'America/New_York';

const tempDate = new Date(targetTimeStr); // 日本のブラウザで解釈
const formatter = new Intl.DateTimeFormat('en-US', { timeZone: targetTimeZone, timeZoneName: 'longOffset' });
const offsetString = formatter.formatToParts(tempDate).find(p => p.type === 'timeZoneName').value.replace('GMT', ''); 
const finalUtcDate = new Date(`${targetTimeStr}${offsetString}`);

console.log('取得されたオフセット:', offsetString); 
// 期待値: -04:00(夏時間) → 実際の結果: -05:00(冬時間)

// --- 検証(答え合わせ) ---
// 求めたUTCが正しいか、もう一度ニューヨーク時間に戻して確認してみます
console.log('現地時間に戻して表示:', finalUtcDate.toLocaleString('ja-JP', { timeZone: targetTimeZone }));
// 期待値: 5:00:00(正しく逆算できていれば、元の時刻に戻るはず)
// 実際の結果: 6:00:00(1時間ズレてしまった!)

コードの内部で起きていること(全手順の解説)

「5:00」と指定したにもかかわらず、画面には「6:00」と表示されています。裏側でブラウザが行っている計算を1ステップずつ追ってみます。

  1. 日本時間としての解釈とUTCへの変換
    new Date('2026-03-08T05:00:00') を実行すると、日本のブラウザはこれを「日本時間(UTC+9)の3月8日 朝5:00」として処理します。
    ブラウザはこれを絶対的な基準である世界標準時(UTC)に変換するため、9時間を引き算します。
    3月8日 05:00 - 9時間 = 「3月7日 20:00 (UTC)」
    この「前日の夜8時」という日時が、UTCとして内部的に保持されます。
  2. ニューヨーク時間での再計算とオフセット取得
    次に Intl は、保持している「UTC 3月7日 20:00」がニューヨークでは何時になるかを計算します。
    3月7日の時点では、ニューヨークはまだ夏時間が始まっていないため、冬時間(UTC-5)です。
    20:00 (UTC) - 5時間 = 「3月7日 15:00」
    本来調べたかったのは3月8日の朝5時なのに、日本との大きな時差のせいで、formatter.formatToParts(tempDate) を実行してオフセットを取得するための判定用の日時が「前日の午後3時」となっています。結果として、ブラウザはまだ冬時間であると判断し、誤ったオフセット「-05:00」を取得してしまいます。
  3. 誤った値でのUTC逆算
    本来は夏時間の「-04:00」を結合すべきところを、取得してしまった「-05:00」を文字列に結合してしまいます。
    new Date('2026-03-08T05:00:00-05:00')
  4. 確認
    確認のためにこのUTCから再びニューヨーク時間として画面に表示してみましょう。
    すると、結果として「6:00」という1時間ずれた時刻が出力されました。
    これはつまり、求めたUTCが正しくないという事を意味しています。

ケース2:アデレード(時差がわずか30分の場合でも起こる不具合)

時差が大きいからずれるのだと思うかもしれませんが、日本とわずか「30分(UTC+9:30)」しか時差がないオーストラリアのアデレード(Australia/Adelaide)でも、不具合が起こります。2026年の夏時間は、10月4日(日)の午前2:00に開始されます。

今度は切り替え直前の「10月4日 午前1:30」を逆算してみます。本来は夏時間前の「+09:30」が取得されるべきです。

【不具合が発生するコード(アデレード編)】

const targetTimeStr = '2026-10-04T01:30:00'; // 取得したい現地時刻(夏時間前)
const targetTimeZone = 'Australia/Adelaide';

const tempDate = new Date(targetTimeStr); 
const formatter = new Intl.DateTimeFormat('en-US', { timeZone: targetTimeZone, timeZoneName: 'longOffset' });
const offsetString = formatter.formatToParts(tempDate).find(p => p.type === 'timeZoneName').value.replace('GMT', ''); 
const finalUtcDate = new Date(`${targetTimeStr}${offsetString}`);

console.log('取得されたオフセット:', offsetString); 
// 期待値: +09:30(夏時間前) → 実際の結果: +10:30(夏時間)

// --- 検証(答え合わせ) ---
console.log('現地時間に戻して表示:', finalUtcDate.toLocaleString('ja-JP', { timeZone: targetTimeZone }));
// 期待値: 1:30:00 → 実際の結果: 0:30:00

コードの内部で起きていること(全手順の解説)

今度は「1:30」が「0:30」へと1時間戻ってしまいました。時差がたった30分しかないのになぜずれるのか、計算を追ってみます。

  1. 日本時間としての解釈とUTCへの変換
    new Date('2026-10-04T01:30:00') は、「日本時間(UTC+9)の10月4日 1:30」として処理されます。
    このコードを日本で動かした場合、UTCに変換するために9時間が引かれます。
    10月4日 01:30 - 9時間 = 「10月3日 16:30 (UTC)」
    これが内部で保持されるUTCの日時です。
  2. アデレード時間での再計算とオフセット取得
    Intl が、「UTC 10月3日 16:30」をアデレード時間(UTC+9:30)に直して判定を行います。
    16:30 (UTC) + 9時間30分 = 「10月4日 02:00丁度」
    どうでしょうか。日本とはわずか30分の時差しかありませんが、オフセットを判定するタイミングが、夏時間が開始される「午前2:00丁度」にピッタリ重なってしまいました。
    結果として、ブラウザは「すでに夏時間である」と誤って判定し、夏時間用の「+10:30」をオフセットとして返してしまいます。
  3. 誤った値でのUTC逆算と時刻のずれ
    本来は夏時間前の「+09:30」を結合すべきところを、取得してしまった「+10:30」を結合してしまいます。
    new Date('2026-10-04T01:30:00+10:30')
    1時間分余計に進んだオフセットを使ってUTCを逆算した結果、検証のために現地時間へ戻した最終的な時刻が1時間巻き戻った「0:30」になってしまうのです。

日本での開発中に見つけた「一見良さそうな」回避策

これらの問題を回避するためには、「オフセットを取得する時刻」が、現地のサマータイム切り替え時間(深夜0時〜3時頃)を絶対にまたがないように工夫する必要があります。

そこで私は、判定用の時刻を一時的に安全な時間帯に置き換えてその日の「暫定オフセット」を取得し、それを使ってから本来の時刻の正確なオフセットを再取得するという「二段階検証」のロジックを考案しました。

では、一時的に置き換える「安全な時間」を何時にすべきか。私は検証の末に「22時」を選択しました。なぜ本来の時刻ではなく、22時という特定の時間を起点にしたのか。それは、日本で開発している環境(JST)において、これが完璧なマジックナンバーに見えたからです。

日本基準で考えた時の「22時の魔法」……とその限界

日本のブラウザで new Date('...T22:00:00') と指定すると、世界標準時(UTC)では9時間引かれて「13:00 (UTC)」として保持されます。
この「UTC 13:00」を基準にして、世界中の都市の現地時間を計算してみるとどうなるでしょうか。

  • ニューヨーク(UTC-5)の場合: 13:00 - 5時間 = 朝の8:00
  • ロンドン(UTC+0)の場合: 13:00 + 0時間 = 昼の13:00
  • アデレード(UTC+9:30)の場合: 13:00 + 9時間30分 = 夜の22:30

「日本での22時」を起点にして世界標準時(UTC 13:00)を作り出すと、多くの主要都市においてサマータイムが切り替わる「深夜0時〜3時の危険地帯」を見事に避けて、安全な日中や夜の時間帯になるのです。

【日本国内であれば「ほぼ」成功するコード】

const targetTimeStr = '2026-10-04T01:30:00';
const targetTimeZone = 'Australia/Adelaide';

function getOffsetAt(dateObj) {
  const formatter = new Intl.DateTimeFormat('en-US', { timeZone: targetTimeZone, timeZoneName: 'longOffset' });
  return formatter.formatToParts(dateObj).find(p => p.type === 'timeZoneName').value.replace('GMT', '') || 'Z';
}

// 1. 時間を「22:00」に強制変更し、その日の暫定オフセットを安全地帯で取得
const safeTimeStr = targetTimeStr.replace(/T\d{2}:\d{2}:\d{2}/, 'T22:00:00');
const provisionalOffset = getOffsetAt(new Date(safeTimeStr)); 

// 2. 暫定オフセットでUTCを仮作成し、本来の時刻の真のオフセットを再取得
const provisionalUtc = new Date(`${targetTimeStr}${provisionalOffset}`);
const finalOffset = getOffsetAt(provisionalUtc);

const finalUtcDate = new Date(`${targetTimeStr}${finalOffset}`);
// 日本国内での多くの都市の判定には有効でしたが、地球規模では限界がありました。

しかし、さらに検証を重ねるうちに、この「魔法」が通用しない極端な地域があることに気づきました。地球の時差の幅は思っている以上に広かったのです。

  • キリバス(UTC+14)の場合:
    世界で最も早く日付が変わるキリバスでは、時差が UTC+14 にもなります。
    13:00 (UTC) + 14時間 = 27:00(翌日の午前3:00)
    これでは判定タイミングが翌日の日付になってしまいますし、さらに午前3時という、まさに夏時間への切り替わりがありそうな「深夜の時間帯」となってしまいます。
  • ベーカー島(UTC-12)などの場合:
    世界で最も時差が遅い UTC-12 の地域ではどうでしょうか。
    13:00 (UTC) - 12時間 = 深夜 01:00
    この様にサマータイム切り替えがありそうな「午前1時」という時間になってしまいます。

かなり多くのタイムゾーンで正常に動く様にはなったのですが、やはり100%カバーするのは無理そうだという結論に辿り着きました。

この極端な時差を持つ都市に気づく前は、「これなら、どんな都市を指定されても絶対に境界線を踏まずに安全なオフセットが取得できる!」と思っていたのですが、そもそも「22時に変更する」や、「キリバスなどの極端な時差だと駄目」などの話以前に、この方法は「最初から完璧に駄目な解決策」だった事に後から気づきます。

最大26時間の壁

最初に書いた当初は完璧だと思っていた「22時の二段階検証」ですが、後から完全では無い事もわかりましたし、このコードをグローバルに公開することを想定し、「もし別の国のユーザーが実行したら……」と考えた時、このロジックは完全に破綻することに気づきました。

当たり前ですが、先の回避コードの中で、全ての国の人のブラウザで基準となるUTCが 13:00 になるわけではありません。当時の私はそんな事はすっかり忘れて「なんとか正しい時刻を標準機能だけで取得してやるぞ」と、意地になっていた様に思います。

ブラウザで時間を固定して判定する場合、ターゲット都市での判定時刻は以下の式で決まります。

判定時刻 = 固定した時間 + (ターゲットの時差 - 実行環境の時差)

地球上のタイムゾーンは、最も遅い地域(UTC-12)から最も早い地域(UTC+14)まで、最大で26時間もの開きがあります。つまり、上記の式における時差の差は -26時間 から +26時間 までのあらゆる値になり得るのです。

先程のコードを日本以外で動かした場合の具体的な例と計算式を見てみましょう。

  • ロサンゼルスのユーザーがニューヨークを調べた場合:
    実行環境であるロサンゼルス(冬時間)は UTC-8 です。
    ターゲットであるニューヨーク(冬時間)は UTC-5 です。
    時差の差分は -5 - (-8) = +3時間 となります。
    これを先ほどの式に当てはめると、22:00 + 3時間 = 25:00(翌日の午前1:00) となります。安全なはずの22時が、サマータイムの境界線に極めて近い深夜1時に変換されてしまい、判定が不安定になります。
  • 日本のユーザーがニュージーランドを調べた場合:
    実行環境である日本は UTC+9 です。
    ターゲットであるニュージーランド(夏時間)は UTC+13 です。
    時差の差分は +13 - (+9) = +4時間 となります。
    式に当てはめると、22:00 + 4時間 = 26:00(翌日の午前2:00) となります。これは、ニュージーランドのサマータイム切り替え時刻である午前2時に完全一致しており、計算がずれてしまいます。

「18時や20時にずらせばいいのでは?」「UTCを基準にすれば……?」と色々と試行錯誤はしてみたのですが、結局は同じでした。最大26時間もの時差の開きがある以上、地球上のすべての組み合わせで境界線を一発で避けられる時間は、どこにも存在しないからです。

冷静に考えると、この「あらかじめ時間をずらして判定する」という作戦は、根本的な矛盾を抱えていました。「何時間ずらせばいいか」を知るために必要な『オフセット』が、まさに今から調べようとしている正解そのものだからです。

まさに「鶏が先か、卵が先か」のループです。この矛盾を自力で、しかも全世界のルールに対して正確に解くロジックを組むのは、流石にリスクが高いと判断しました。

まとめ:素直に諦めてライブラリを使う決断

最終的に、私が意地になって取り組んでいたコードは、最大26時間という世界の時差のスケールの前に、あっけなく崩れ去りました。

JavaScriptの標準機能(DateIntl)の組み合わせだけで、世界中からのアクセスを想定した正確なタイムゾーンの計算を標準機能だけで完璧に処理するのは、思わぬバグを生むリスクが高く、非常に困難だという結論に至りました。

標準機能だけで何とか解決しようと重ねてきた工夫と努力にはここで区切りをつけ、素直にライブラリの力を借りることにしました。
次回は、こうした複雑な実行環境の差異によるバグを根本から解決し、安全かつ正確に時差計算を行ってくれる強力なライブラリ date-fns-tz を使った実装方法をご紹介します。