playpark で自作している営業管理ダッシュボードには、案件の対応期限をアラート表示する画面がある。期限が今日を過ぎたら赤(期限超過)、7日以内なら黄色(期限間近)、それ以外はグレー。ロジックはシンプルで、しばらく安定稼働していた。
ところがある時期、「今日が期限のはずの案件が、朝の時点ではまだ黄色のまま」という報告が上がってきた(営業担当からすれば、もう催促していいのか、まだ待つべきなのか判断がつかない気まずい朝を過ごす羽目になる)。再現条件を洗っていくと、朝の早い時間帯だけ起きる、という点が引っかかった。昼過ぎに見ると直っている。つまり日付が変わるタイミングそのものにズレがある。
原因はダッシュボードのサーバーサイドコードにあった。「今日の日付」をnew Date().toISOString()ベースで作っていたのだが、デプロイ先のサーバーはUTCで動いている。日本時間(JST)はUTC+9時間なので、JST朝7時台はまだUTCでは前日の22時台だ。この「今日」の1日ズレが、期限判定を狂わせていた。たかが9時間のオフセットで、なぜここまで話がややこしくなるのだろうか(正直、原因を突き止めるまでは半信半疑だった)。
この記事で学べること
new Date().toISOString()をサーバーの「今日」に使うと、JST早朝の時間帯だけ日付が1日ずれる理由Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Tokyo" })でタイムゾーンを固定した「今日」を作るヘルパーの書き方- 日付計算のヘルパーを複数箇所に散らばせず1箇所に統一する直し方
- サーバーとクライアントで日付計算がズレるとhydration mismatchが起きる、という二段目の罠とその避け方
前提条件
- Next.js App Routerで、Server Component側で日付・期限判定を行っている
- デプロイ先がVercelなど、サーバーの実行タイムゾーンがUTC固定の環境
- 「今日」を跨ぐ判定(期限超過・当日期限・期限間近など)をアプリ内で持っている
実装方法
バグの正体: サーバーの「今日」はUTCで決まる
問題の箇所は、期限までの残り日数から警告レベルを判定する関数だった。todayを関数の外、つまり呼び出し側で毎回new Date()相当の値から作っていたのだが、その生成方法がUTC基準になっていた。JST早朝(UTC前日夜)の時間帯だけ「今日」がまだ昨日のままなので、当日が期限の案件が「まだ期限間近(黄色)」と判定され続けてしまう。
タイムゾーンをまたぐ日付比較のズレを図にすると、こうなる。
素朴にtoISOString()を使うと、UTC基準の日付文字列が返ってくる。人間の体感(JST)とサーバーの時計(UTC)の間に最大9時間のズレがあり、そのズレがちょうど日付の境界をまたぐ時間帯(JSTでいう朝0時〜9時)に、今日の判定だけが1日遅れる。
todayInJst()でタイムゾーンを固定する
直し方はシンプルで、「JST基準のYYYY-MM-DDを返すヘルパー」を1つ作り、日付が絡む箇所は必ずそこを通すようにした。実装はIntl.DateTimeFormatにタイムゾーン指定を渡すだけで済む。
/**
* JST (Asia/Tokyo) 基準の今日を `YYYY-MM-DD` で返す。
* 業務は JST だが Vercel サーバーは UTC なので、`new Date().toISOString()` を
* そのまま使うと JST 早朝(UTC 前日)の時間帯で日付が 1 日ずれて期限超過判定を
* 誤る。期限関連の計算は必ずこのヘルパーを通すこと。
*/
export function todayInJst(now: Date = new Date()): string {
return new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Tokyo" }).format(now);
}
ポイントは"en-CA"ロケールを使っていること。カナダ英語のロケールはYYYY-MM-DD形式で日付を返してくれる仕様なので、フォーマット処理を自分で書かずに済む(ロケール名だけ見ると謎の選択に見えるが、単なる「YYYY-MM-DD形式が欲しいから」という実用的な理由)。timeZone: "Asia/Tokyo"を渡すことで、サーバーの実行環境が何時であっても、常にJST基準の日付が返る。
判定側の関数も、比較を全部文字列比較にしている。YYYY-MM-DD形式の文字列同士なら、辞書順比較がそのまま日付の前後関係と一致するので、いちいちDateオブジェクトに戻す必要がない。タイムゾーンの概念自体を計算から締め出せるのがこの形式の利点だ。
export function getAlertLevel(
deadline: string,
today: string = todayInJst(),
): "overdue" | "today" | "upcoming" | "none" {
// 文字列 (YYYY-MM-DD) 同士の比較で TZ を排除する
if (deadline < today) return "overdue";
if (deadline === today) return "today";
// 7 日後 (YYYY-MM-DD) を文字列で算出
const t = new Date(`${today}T00:00:00Z`);
t.setUTCDate(t.getUTCDate() + 7);
const weekLater = t.toISOString().slice(0, 10);
if (deadline <= weekLater) return "upcoming";
return "none";
}
判定ロジックを表にすると次のようになる。
| deadline と today の関係 | 返り値 |
|---|---|
| deadline < today | overdue(期限超過) |
| deadline === today | today(本日期限) |
| today < deadline ≦ today+7日 | upcoming(期限間近) |
| それ以外 | none |
呼び出し元を1箇所のヘルパーに統一する
もう一つ効いたのが、「今日」を計算している箇所を全部洗い出して、todayInJst()に寄せたことだ。アラート一覧を組み立てる関数、軽量版のアラート取得関数、ナビバーの期限超過バッジ用カウント関数——期限判定が絡む処理は思ったより複数箇所に散らばっていた(メインの判定ロジックだけ直して満足していたら、ナビバーのバッジだけこっそり同じバグを生き延びさせるところだった)。
export function getAlerts(companies: Company[]): Alert[] {
const today = todayInJst();
// ...
}
export const loadAlertData = cache(async (): Promise<Alert[]> => {
// ...
const today = todayInJst();
// ...
});
export async function loadOverdueAlertCount(): Promise<number> {
const todayStr = todayInJst();
// ...
}
こうしておくと、「今日の計算方法を直したはずなのに、別の画面のバッジだけ直し漏れていた」という事故を防げる。日付処理は1箇所直せば全部直る状態を維持するのが、この手のバグの再発防止として一番効く。
動作確認
修正の裏付けとして、JST早朝の時間帯を明示的に再現するテストを追加した。UTC 22:36(=JST翌朝07:36)を固定時刻にして、その状態でも前日の日付が正しく「期限超過」と判定されることを確認するケースだ。
describe("getAlertLevel with JST today (regression for UTC bug)", () => {
beforeEach(() => {
// UTC 2026-05-10 22:36 / JST 2026-05-11 07:36
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-10T22:36:00Z"));
});
it("treats deadline 2026-05-10 as overdue when JST today is 2026-05-11", () => {
expect(getAlertLevel("2026-05-10")).toBe("overdue");
});
});
このテストが直った後も緑のままでいてくれれば、「JST早朝だけ再発する」バグを二度と踏まないという保証になる。実行時刻に依存するバグは再現条件を固定してテストしないと、直した気になって同じ穴にもう一度落ちる(このバグ自体、9時間のズレという地味な数字が原因で、体感的には「なんか朝だけおかしい」としか表現しようがない不気味さがあった)。
二段目の学び: サーバーで計算した「今日」はクライアントで作り直さない
ここまでで日付計算自体は直った。「これで一件落着のはず」と思っていたのだが、現実はもう一段深い設計判断が必要になった。「アラートレベルの計算はどこで行うべきか」という話だ。
getAlertLevelはサーバー・クライアントどちらでも呼び出せる純粋な関数として書かれている。ここで「毎回のレンダーで呼べばいいのでは」と考えたくなるが、それをクライアントコンポーネント側でもやると危険なことに気づいた。Reactのサーバーコンポーネントはサーバー(UTC環境)でレンダーされ、その結果をクライアント(ブラウザ、実質JST基準の時計を持つ利用者)が再レンダーして一致を確認する。もしアラートレベルの計算をクライアント側でも独自に走らせていたら、サーバー(UTC)とクライアント(JST)で「今日」の認識がズレたままレンダー結果を突き合わせることになり、hydration mismatch(サーバーとクライアントの描画結果が食い違うReactのエラー)を起こす。
この判断はgetAlertLevelのJSDocコメントにそのまま残してある。
/**
* Determine alert level. `today` を呼び出し側で固定して渡す前提(サーバー側で計算)。
* クライアントで毎レンダ計算するとサーバー(UTC)とクライアント(JST)で結果が変わり
* hydration mismatch を起こすため、サーバーで一度だけ計算して prop で渡す運用にする。
*/
同じ判断は、アラートの型定義側のコメントにも書いてある。
export interface Alert {
// ...
/** サーバー側で計算した期限レベル。クライアントでの再計算はタイムゾーン差で hydration mismatch を起こすため必ず prop で受け取る。 */
level: AlertLevel;
}
つまり、「日付計算をJST基準に直す」という一段目の修正だけでは終わらず、「その計算結果をどこで確定させ、どう渡すか」という設計判断まで一緒にやる必要があった。サーバー側で一度だけgetAlertLevelを呼んでAlertオブジェクトにlevelとして確定させ、それをpropとしてクライアントコンポーネントに渡す。クライアント側では二度とgetAlertLevelを呼ばせない。この制約をコメントに明示しておかないと、「レンダーのたびに最新の判定が欲しいから」という善意の理由で、誰かがクライアント側に同じ関数呼び出しを書き足してしまいそうな設計だった。
タイムゾーンのズレは、日付計算そのものだけでなく「その計算をどこで何回行うか」というアーキテクチャの問題にも波及する、というのが今回の一番の学びだった。
注意点・Tips
- サーバーの実行タイムゾーンを前提にしない:
new Date()やその文字列化メソッドは全部サーバーの実行環境(多くの場合UTC)に依存する。業務がJST基準なら、日付を作る場所は必ずタイムゾーンを明示するヘルパーに統一する。 - 日付比較はYYYY-MM-DD文字列で行う: Dateオブジェクト同士の比較よりも、
YYYY-MM-DD形式の文字列比較の方がタイムゾーンの概念そのものを持ち込まずに済み、バグの余地が減る。 - タイムゾーンをまたぐバグはピンポイントの時刻でテストする: 「JST早朝=UTC前日夜」のような境界時刻を
vi.setSystemTime等で固定し、再現条件を明示的にテストに残す。 - サーバー計算とクライアント再計算を混在させない: タイムゾーン依存の計算はサーバーで一度だけ確定させ、結果をpropで渡す。クライアント側で同じ関数を「念のため」呼び直すと、今回のようなhydration mismatchの火種になる。
まとめ
期限超過アラートが朝だけ赤くならない、という一見地味な不具合の裏には、「サーバーはUTCで動いている」という前提を日付計算のどこかで忘れていた、というよくある原因があった。Intl.DateTimeFormatでタイムゾーンを固定したヘルパーに日付計算を1箇所へ集約すれば直る話ではあるのだが、直す過程で「その計算結果をサーバー・クライアントのどちらで、何回行うか」という設計判断まで踏み込む必要が出てきたのが今回のポイントだった。Next.jsでサーバーコンポーネントとクライアントコンポーネントが混在する構成では、タイムゾーン依存の値は「どこで1回だけ確定させるか」まで含めて設計しておかないと、直したはずのバグの隣に新しいバグを生みかねない。



