はじめに
HTML の <input type="datetime-local"> は、日時入力のための便利なフォーム要素だ。カレンダーピッカーが表示され、ユーザーは直感的に日時を選択できる。
ところが、この要素には厄介な特性がある。返り値にタイムゾーン情報が含まれない。
「タイムゾーンがない」と聞いても、あまりピンと来ないかもしれない。実際、私も最初は軽く考えていた。フォームに入力された値をそのまま API に送り、Supabase の timestamptz カラムに保存する。それだけのことだと思っていた。
しかし、この「それだけのこと」が、同じプロダクトで 2度にわたって納期を8時間ズラすバグを引き起こした。しかも2度目は、1度目の修正から わずか5日後のことだった。
この記事では、datetime-local と timestamptz の組み合わせで発生する二重タイムゾーン変換の問題を、実際の開発記録をもとに解説する。同じ罠を踏まないための「決定版」パターンとしてまとめたい。
背景: フリーランス向け業務管理ツールの納期機能
私が PM として開発している Wakulier は、フリーランスや副業クリエイター向けの業務管理ツールだ。クライアントからの依頼を受け、納期を管理し、進捗を追跡する。当然ながら、納期はこのツールの最重要データの1つになる。
納期の入力には datetime-local を採用した。カレンダーとタイムピッカーが一体になったこの入力要素は、ユーザー体験の面では申し分ない。日付と時刻を一度に選択できるし、ブラウザネイティブの UI なのでモバイルでも使いやすい。
バックエンドには Supabase(PostgreSQL)を使っている。納期カラムの型は timestamptz(timestamp with time zone)。PostgreSQL が推奨するベストプラクティスに従った選択だ。
フロントエンドの datetime-local と、バックエンドの timestamptz。どちらも「正しい選択」のはずだった。問題は、この2つの間に暗黙の変換が挟まることだった。
1回目の遭遇: 納期が8時間ズレる
2025年12月31日。大晦日だった。年の最後の開発日に、厄介なバグと向き合うことになった。
開発日記にはこう書いた。
納期が8時間ズレる問題が発生。
クライアント向けの依頼フォームで納期を「1月10日 18:00」と入力したはずなのに、管理画面では「1月11日 02:00」と表示される。8時間のズレ。9時間ではなく8時間だった点が、最初は混乱の元になった。しかし本質的な原因は同じだ。タイムゾーンの二重変換が起きていた。
二重変換の仕組み
Claude Code に原因の調査を依頼して追跡していくと、データが以下の経路で変換されていることがわかった。
ステップ1: フォーム入力
ユーザーが datetime-local で「2026-01-10T18:00」を選択する。この値にはタイムゾーン情報がない。ブラウザは単に 2026-01-10T18:00 という文字列を返すだけだ。
ステップ2: API 受信
この値をそのまま API に送信する。API 側(Next.js の Server Action や Route Handler)で受け取った文字列を JavaScript の new Date() に渡すと、サーバーのローカルタイムゾーンで解釈される。Vercel の場合は UTC だ。
つまり 2026-01-10T18:00 は「UTC 18:00」として扱われる。しかし、ユーザーが意図していたのは「JST 18:00」だ。
ステップ3: DB 保存
Supabase の timestamptz カラムに保存される。PostgreSQL は受け取った値を UTC に正規化して保存する。既に UTC として解釈されているため、ここでは変換は起きない(ように見える)。
ステップ4: 読み出し
データを読み出す際、Supabase クライアントは timestamptz の値を ISO 8601 形式で返す。フロントエンドで表示するとき、ブラウザの Date オブジェクトがこれを JST に変換する。
ここで問題が起きる。元々「JST 18:00」のつもりで入力した値が、ステップ2で「UTC 18:00」として処理され、ステップ4で「JST に変換」される。結果として「JST 03:00(翌日)」になる。9時間のズレ。これが二重変換の正体だ。
図にするとこうなる。
ユーザー入力: 18:00(JST のつもり)
↓ datetime-local(タイムゾーンなし)
API 受信: 18:00 → UTC 18:00 と解釈
↓
DB 保存: UTC 18:00
↓
読み出し: UTC 18:00 → JST 03:00(+9時間)
↓
表示: 03:00 ← 8〜9時間ズレている!
ユーザーが入力した「18:00」が「03:00」になる。フリーランスの納期管理ツールで、これは致命的だ。「今日の18時まで」が「明日の3時まで」になってしまう。
1回目の修正
開発日記に記録した修正方針はシンプルだった。
入力時点で明示的に+09:00を付与してパース。
datetime-local が返す値にタイムゾーン情報がないなら、送信前にこちらで付与する。
// datetime-local の値: "2026-01-10T18:00"
const datetimeLocalValue = "2026-01-10T18:00";
// ❌ 修正前: タイムゾーンなしでそのまま送信
await saveDeadline(datetimeLocalValue);
// ✅ 修正後: JSTオフセットを明示的に付与
const isoString = `${datetimeLocalValue}:00+09:00`;
await saveDeadline(isoString);
+09:00 を付与することで、API やデータベースが値を受け取った際に「これは JST 18:00 である」と正しく解釈できるようになる。二重変換は発生しない。
大晦日の深夜、Claude Code で修正を実装してデプロイし、問題は解消した。開発日記の最後にはこう書いている。
2025年最後の開発日。年末年始も開発を続ける。
年末の一仕事を終えた安堵感があった。しかし、この問題が5日後に再び姿を現すとは思ってもいなかった。
2回目の遭遇: 納期変更提案でも同じ罠
2026年1月5日。年が明けて最初の週、Wakulier に「納期変更提案」機能を追加していた。
クリエイターがクライアントの提示した納期に対して、別の日時を提案できる機能だ。依頼を受けたものの「この納期では厳しい」という場面は、フリーランスならよくある話だろう。提案した納期をクライアントが承諾すれば、新しい納期として確定する。
この機能をテストしていて、見覚えのある症状に遭遇した。
提案納期を保存 → 表示時に9時間ズレる
さらに厄介なことに、承諾フローにも問題があった。
承諾時に再度ズレる
提案の保存で1回、承諾の処理でさらに1回。タイムゾーンの変換が2箇所で発生していた。
5日前にまったく同じ問題を修正したばかりなのに、なぜ再発したのか。答えは単純だ。修正を適用したのは依頼フォームの納期入力だけで、新しく追加した「納期変更提案」の入力は別のコンポーネントだったからだ。
datetime-local を使う箇所が増えるたびに、同じ修正が必要になる。しかも、コードレビューの段階では「datetime-local の値をそのまま送信している」ことに気づきにくい。動作としては一見正しく見えるからだ。ローカル環境で Node.js が JST で動いていれば、ズレは発生しない。
2回目の修正
修正方法は1回目とまったく同じだ。
// 送信時に明示的にJSTオフセットを付与
const isoString = `${datetimeLocalValue}:00+09:00`;
1行の修正。しかし、この1行を忘れるだけで、納期が9時間ズレる。
開発日記の「学んだこと」にはこう記録した。
datetime-localは必ずタイムゾーンを明示してパース
記事ネタメモには苦笑を込めてこう書いていた。
datetime-localとタイムゾーンの罠(3回目)
「3回目」という表現は、大晦日の修正を含めて複数回この問題に遭遇した実感を反映している。同じパターンが異なるフォームで繰り返し発生する、という性質がこの罠の厄介なところだ。
なぜ繰り返しハマるのか
datetime-local のタイムゾーン問題は、一度理解すれば「なんだそんなことか」と思える。しかし、繰り返しハマる理由がいくつかある。
理由1: datetime-local の仕様が直感に反する
<input type="datetime-local"> の返り値は 2026-01-10T18:00 のような文字列だ。見た目は ISO 8601 に近いが、タイムゾーン情報が省略されている。
これが問題の本質だ。人間が見れば「ローカル時間だろう」と推測できる。しかし、サーバー側の JavaScript はそうは解釈しない。タイムゾーン指定がなければ、実行環境のタイムゾーンで解釈する。
ローカル開発環境が JST なら問題は表面化しない。Vercel(UTC)にデプロイして初めて顕在化する。この「ローカルでは動くが本番でバグる」パターンが、発見を遅らせる。
理由2: 新しいフォームで忘れる
1つのフォームで修正しても、同じプロダクト内に別のフォームを追加すれば、同じ修正が必要になる。これは DRY(Don't Repeat Yourself)の原則に反する状態だ。
修正を知っている開発者が書いても忘れることがある。ましてや、チームに新しいメンバーが加わったときには、ほぼ確実に同じバグを踏む。
理由3: テストで検出しにくい
ローカル環境のテストでは問題が発生しないため、ユニットテストや E2E テストを書いても検出が難しい。テスト環境のタイムゾーンを UTC に設定すれば検出できるが、そもそも「テストすべきだ」と気づくためには、一度ハマる必要がある。
決定版パターン: datetime-local を安全に扱う
2度の遭遇を経て、以下のパターンを確立した。
パターン1: 送信時にオフセットを付与する関数を用意する
datetime-local の値を API に送る前に、必ずこの関数を通す。
function toJstIsoString(datetimeLocalValue: string): string {
return `${datetimeLocalValue}:00+09:00`;
}
// 使用例
const deadline = toJstIsoString(formData.deadline);
// "2026-01-10T18:00" → "2026-01-10T18:00:00+09:00"
関数にしておくことで、「datetime-local の値をそのまま送信しない」というルールを仕組みで強制できる。コードレビューでも datetimeLocalValue が直接 API に渡されていないかチェックしやすくなる。
パターン2: 表示時は Date オブジェクトに任せる
timestamptz から読み出した値は、ISO 8601 形式(2026-01-10T09:00:00+00:00)で返ってくる。これをブラウザの Date オブジェクトに渡せば、自動的にユーザーのローカルタイムゾーンに変換される。
// Supabase から取得した値
const utcString = "2026-01-10T09:00:00+00:00";
// toLocaleString で JST に変換
const display = new Date(utcString).toLocaleString("ja-JP", {
timeZone: "Asia/Tokyo",
// ...フォーマットオプション
});
読み出し側では特別な処理は不要だ。入力時にオフセットを正しく付与していれば、出力は自動的に正しくなる。これが、入力側での修正が「決定版」たる理由だ。
パターン3: ユーザーのタイムゾーンを考慮する(発展)
ここまでの例では +09:00(JST)をハードコードしてきた。日本国内向けのサービスならこれで十分だが、グローバル展開を見据えるなら、ユーザーのタイムゾーンを動的に取得する方法もある。
function toIsoStringWithOffset(
datetimeLocalValue: string
): string {
const date = new Date(datetimeLocalValue);
const offset = -date.getTimezoneOffset();
const sign = offset >= 0 ? "+" : "-";
const hh = String(Math.floor(Math.abs(offset) / 60)).padStart(2, "0");
const mm = String(Math.abs(offset) % 60).padStart(2, "0");
return `${datetimeLocalValue}:00${sign}${hh}:${mm}`;
}
ブラウザの getTimezoneOffset() を使えば、ユーザーのタイムゾーンオフセットを取得できる。ただし、日本向けサービスであれば JST 固定のほうがシンプルで保守しやすい。過度な汎用化は別の問題を生むことがある。
サーバー側の罠との比較
以前の記事「Vercel(UTC)で JST 日時処理にハマった話」では、サーバー側のタイムゾーン問題を扱った。setHours() や setDate() といった JavaScript の Date メソッドが、Vercel の UTC 環境で想定外の挙動をする問題だ。
今回の datetime-local の問題は、フロントエンド側のタイムゾーン問題だ。2つの記事を合わせると、Web アプリケーションにおけるタイムゾーン問題の全体像が見えてくる。
| 側面 | サーバー側(Vercel UTC 記事) | フロントエンド側(本記事) |
|---|---|---|
| 原因 | サーバーが UTC で動作 | datetime-local にタイムゾーンがない |
| 発生箇所 | Date メソッド(setHours 等) | フォーム入力 → API 送信 |
| 発見タイミング | デプロイ後 | デプロイ後(またはDB保存後) |
| 対策 | 明示的なオフセット指定 | 送信前にオフセットを付与 |
| 共通点 | 「暗黙のタイムゾーン変換」を排除すること | 同左 |
共通する教訓は明確だ。タイムゾーンは常に明示する。暗黙の変換に頼った瞬間、環境依存のバグが生まれる。
学んだこと
2度の遭遇と修正を経て、いくつかの教訓を得た。
教訓1: 「1回修正した」では不十分
同じプロダクト内でも、datetime-local を使うフォームが増えれば、同じ修正が必要になる。修正をユーティリティ関数に切り出し、すべての入力箇所で使うようにすること。これは技術的な話だけでなく、プロジェクトのナレッジとしてドキュメント化しておくべきだ。
教訓2: ローカルで動いても本番で動くとは限らない
datetime-local のタイムゾーン問題も、Vercel の UTC 問題も、ローカル開発環境(JST)では表面化しない。「ローカルで問題なし」は安心材料にならない。特に日時処理では、UTC 環境でのテストを習慣にする必要がある。
教訓3: フロントエンドとバックエンドの「境界」に注意する
datetime-local の返り値は「タイムゾーンなしのローカル時間」。timestamptz は「タイムゾーン付きの時刻」。この2つの間には暗黙の変換が存在し、そこがバグの温床になる。
異なるシステム間でデータを受け渡すとき、データの解釈が両者で一致しているかを確認する習慣をつけたい。タイムゾーンに限らず、文字コード、数値の精度、日付フォーマットなど、同じ原則が当てはまる。
教訓4: 「決定版」を作って共有する
同じ問題に2度遭遇したら、それは「個別の修正」ではなく「パターン」として確立すべきサインだ。ユーティリティ関数を作り、プロジェクトの CLAUDE.md やコーディング規約に記載し、チーム内で共有する。個人開発であっても、未来の自分は「別の人」だ。
まとめ
datetime-local は便利なフォーム要素だが、返り値にタイムゾーン情報が含まれないという仕様が、Supabase の timestamptz との組み合わせで二重変換バグを引き起こす。
対策はシンプルだ。
- 送信前にオフセットを付与:
${datetimeLocalValue}:00+09:00 - ユーティリティ関数に切り出す: すべてのフォームで同じ関数を使う
- 本番環境(UTC)でテストする: ローカル(JST)で動いても安心しない
「Vercel(UTC)で JST 日時処理にハマった話」がサーバー側のタイムゾーン問題をカバーしているなら、本記事はフロントエンド側の補完だ。両方を押さえておけば、日本向け Web アプリケーションのタイムゾーン問題はほぼ網羅できるだろう。
年末年始にかけて同じバグに2度ハマった経験は、正直なところ悔しかった。しかし、だからこそ「決定版」としてまとめる意味がある。この記事が、同じ罠を踏む人を1人でも減らせれば幸いだ。
関連記事
Zeronova(ゼロノバ)
Product Manager / AI-Native Builder
Web/IT業界19年以上・20以上のWebサービスを担当したPdM。東証プライム上場企業の子会社代表として事業経営を経験。現在はAIを駆使して企画から実装まで完結させる個人開発を実践中。