1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

toISOString()でdatetime-localにsetしたら9時間ズレた話 — 予約投稿系で地雷になるタイムゾーン設計

1
Posted at

TL;DR

  • Date.prototype.toISOString()常にUTCを返す
  • <input type="datetime-local">タイムゾーン情報を持たないローカル時刻文字列YYYY-MM-DDTHH:mm)を期待する
  • この2つを組み合わせると、JST環境では編集画面が9時間ズレる
  • 解決策は「表示JST / 保存UTC / 変換は1関数に集約」

環境

  • フロント: React 18 + TypeScript 5
  • 入力UI: <input type="datetime-local">
  • バックエンド: Node.js 20 / PostgreSQL(timestamptz
  • タイムゾーン: サーバーUTC固定、クライアントはブラウザロケール(主にJST)

発生した症状

予約投稿機能を持つ管理ダッシュボードで、次のような挙動に遭遇しました。

  • 一覧画面: 投稿予定時刻が 2026-04-20 12:00 と表示される
  • 編集画面: 同じレコードを開くと 2026-04-20 03:00 にセットされている
  • DB: timestamptz2026-04-20 03:00:00+00 が保存されている

DB値は正しい(JST 12:00 = UTC 03:00)。壊れていたのは編集画面の初期値セット処理だけでした。

原因: toISOString() × datetime-local の相性

問題のコードはこうでした。

// ❌ NG: 9時間ズレる
const initialValue = new Date(record.scheduledAt).toISOString().slice(0, 16);
// → "2026-04-20T03:00"  (UTC)

toISOString() は仕様上常に Z(UTC)を付与して返す。一方 datetime-localvalue 属性は「ユーザーのローカルタイムゾーンにおける時刻」を文字列として扱うため、UTC文字列をそのまま突っ込むと、ユーザーから見ると9時間ズレて見える。

ブラウザは value の文字列をそのまま表示します。タイムゾーン変換はしてくれません。ここが最大の落とし穴です。

修正: JST文字列を直接作る

// ✅ OK: JSTに寄せてから datetime-local 文字列を作る
function toDatetimeLocalJST(iso: string): string {
  const d = new Date(iso);
  // UTC基準の値を +9h してから、UTCメソッドで読む
  const jst = new Date(d.getTime() + 9 * 60 * 60 * 1000);
  const yyyy = jst.getUTCFullYear();
  const mm = String(jst.getUTCMonth() + 1).padStart(2, "0");
  const dd = String(jst.getUTCDate()).padStart(2, "0");
  const hh = String(jst.getUTCHours()).padStart(2, "0");
  const mi = String(jst.getUTCMinutes()).padStart(2, "0");
  return `${yyyy}-${mm}-${dd}T${hh}:${mi}`;
}

ポイントは 「ズラしてからUTCメソッドで読む」getHours() などローカル系APIを使うと、ブラウザのロケール依存になってテストがブレます。

保存時は逆変換します。

// datetime-local の文字列(JST前提)→ UTC ISO にして送信
function fromDatetimeLocalJST(value: string): string {
  // "2026-04-20T12:00" を JST 扱いでUTCに
  return new Date(`${value}:00+09:00`).toISOString();
}

+09:00 をサフィックスで付けることで、ブラウザロケールに関係なくJST解釈になります。

表示・保存・変換のルール

チーム規約として以下を徹底すると再発しません。

レイヤ タイムゾーン
DB UTC timestamptz
API (送受信) UTC ISO 8601 with Z
UI表示 JST YYYY-MM-DD HH:mm
変換関数 1ファイルに集約 utils/datetime.ts

変換関数を1箇所にまとめるのが最重要です。コンポーネントごとに new Date(...).toISOString() のような処理を書き散らかすと、今回のような事故が必ず起きます。

なぜ開発環境で気づきにくいのか

  • 開発者のローカルタイムゾーンがJST
  • Date.prototype.toString() はローカルタイムゾーンで表示する
  • なので console.log(new Date(value)) してもそれっぽく見える
  • ブラウザの datetime-local 初期値も「ズレてるけど一応それっぽい時刻」として表示される

JSTとUTCが9時間差なので「昼の予定が朝にズレる」程度で済み、目視チェックでスルーされやすい。DSTがある地域(英国など)だと夏冬で挙動が変わり、さらに厄介です。

テスト観点

予約投稿系では最低でも以下は自動テストにしておきたいです。

  • JST 0:30(UTCでは前日15:30)を編集画面で開いて「00:30」と表示されるか
  • 日付をまたぐ時刻の保存・再読み込みで値が変わらないか
  • TZ=UTC node test のようにサーバー側のタイムゾーンを固定してテストする

CIがUTCコンテナで動くのに、ローカルテストだけJSTで通っている、という状態だと本番で事故ります。

まとめ

  • toISOString() は便利だが、UI層の入力値生成に使うとタイムゾーン事故の原因になる
  • datetime-local input はタイムゾーン情報を持たないリテラル文字列として扱う
  • 「表示JST / 保存UTC / 変換1関数」を守る
  • 開発環境のタイムゾーンをCIと揃える or テストで TZ=UTC を固定する

地味な領域ですが、予約系・通知系のサービスでは一発でユーザーの信頼を失うバグになります。早めに規約化しておくのがおすすめです。


この記事を書いた人

BENTEN Web Works — 業務自動化・システム開発のフリーランスエンジニアです。

GAS / Python / RPA を使った業務自動化や、Web制作・システム開発のご相談を承っています。
「こんなこと自動化できる?」というご質問だけでもお気軽にどうぞ。

👉 BENTEN Web Works — 詳細・お問い合わせはこちら
🐦 X(旧Twitter) — 日々の知見を発信中

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?