IT技術ブログ

予約の空き枠と締め切りを、日本時間で正しく扱う実装。DBはUTCで保存し、判定と表示はAsia/Tokyoで行う。UTC 15時の日付の境界、「前日18時締め切り」の計算、Temporal・Intl・date-fns系の選び方、境界を固定するテスト

予約の枠はDBにUTCの時刻で持ち、日付の判定と表示だけを日本時間で行う実装です。「前日18時締め切り」の計算、その日の枠を引くSQL、Temporalの対応状況、手元では通り本番で壊れる書き方を、境界を固定したテストで確かめました。

この記事の結論:予約の枠は、DBに「UTCの時刻」(PostgreSQLならtimestamptz)で持ち、「今日」「前日」「18時」といった日本の暦と時計に関わる判定と表示だけを、Asia/Tokyoを指定して行います。「前日18時締め切り」は、①枠の開始を日本時間の日付にし、②暦の上で1日戻し、③その日の18時を+09:00付きで時刻に戻す、の3段で求めます。サーバーのタイムゾーンを使うgetDate()やsetHours()は使わず、UTCの15時前後・月末・年末・うるう年をテストで固定し、そのテストをタイムゾーンを変えて流します。

この記事のコードとテストは、手元のMacで実際に動かしたものです。テストはNode.js標準のnode:testで、環境変数TZをUTC・Asia/Tokyo・America/Los_Angelesに変えて流しました。SQLは、PGlite(WebAssembly版のPostgreSQL)で流しています。よくある書き方が「手元のMacでは7件中6件通り、UTCのサーバーでは7件すべて落ちる」ところも、そのまま載せます。

時刻はUTCで保存し、日本時間は判定と表示のときだけ使う

予約の枠の「開始時刻」は、世界のどこから見ても同じ1つの瞬間です。DBにはその瞬間をUTCで持ち、日本の暦や時計で考える必要がある場面、つまり「今日の枠」「前日の締め切り」「画面の表示」のときだけ日本時間に直します。

PostgreSQLのドキュメントには、timestamp with time zone(timestamptz)の値は、入力にタイムゾーンが付いていればそのずれでUTCに直し、どの場合も中ではUTCとして保存する、と書かれています。取り出すときは、接続のTimeZoneの設定に合わせた表示になるので、設定が違うサーバーから読んでも、指している瞬間は変わりません。

値の種類ごとに、持ち方を分けておきます。

値の例 意味 持ち方
枠の開始・予約を受けた時刻・締め切りを過ぎた時刻 時刻そのもの timestamptz(UTC)
休業日・臨時営業日・フェアの開催日 日本の暦の日付 date(タイムゾーンなし)
「毎日10:00〜18:00」のような営業時間 日本の時計の時刻 time(タイムゾーンなし)と、どの国の時計かを決めておく

「日付だけの値」までUTCの時刻にしてしまうと、「10月9日の休業日」が「10月8日15時」として保存され、読む側が日本時間に戻し忘れたときに1日ずれます。時刻と日付は、型の段階で分けておくのが安全です。

日付の境界はUTCの15時で、ここで「今日」と「前日」がずれる

日本時間の0時は、UTCでは前日の15時です。UTCの14:59:59と15:00:00のあいだで、日本時間の日付は1日進みますが、UTCの日付は変わりません。

2026-10-08T14:59:59Z → 日本時間 2026-10-08 23:59:59
2026-10-08T15:00:00Z → 日本時間 2026-10-09 00:00:00

予約でこの差が表に出るのは、主に次の3か所です。

  • 「今日の枠」の一覧:日本時間の0時〜9時のあいだに開くと、UTCで日付を出す実装は前日の一覧を出す
  • 朝9時より前に始まる枠の「前日」:日本時間10月10日8時30分の枠は、UTCでは10月9日23時30分。UTCの日付から1日戻すと、10月8日になる
  • 月末・年末・うるう年:上のずれが月や年の変わり目に重なると、「10月31日」のはずが「10月30日」、「2月29日」のはずが「2月28日」になる

サーバーのタイムゾーンに頼る書き方は、手元では通り、本番で壊れる

DateのgetDate()やsetHours()は、プログラムが動いているマシンのタイムゾーンで計算します。日本の手元のMacでは日本時間で計算されるので正しく見えますが、UTCで動くサーバーやCIでは、同じコードが9時間ずれた答えを返します。

MDNのDateの説明にも、ローカルのタイムゾーンはDateの値には保存されず、動いている環境で決まると書かれています。さらに、"2026-10-09T00:00:00"のようにオフセットの無い日時の文字列は「ローカル時刻」として、"2026-10-09"のような日付だけの文字列は「UTC」として解釈される、という決まりもあります。Node.jsでは、環境変数TZでこのローカルのタイムゾーンを変えられるので、テストではTZを変えて同じテストを流します。

よく見かける書き方を、そのまま関数にしました。

naive.mjs(よくある書き方)js
// naive.mjs — よく見かける書き方。サーバーのタイムゾーン(TZ)に結果が左右される
export function dateKey(instant) {
  return instant.toISOString().slice(0, 10); // UTC の日付になる
}
export function deadlineOf(slotStartsAt) {
  const d = new Date(slotStartsAt);
  d.setDate(d.getDate() - 1); // 「前日」…サーバーのタイムゾーンでの前日
  d.setHours(18, 0, 0, 0);     // 「18時」…サーバーのタイムゾーンでの18時
  return d;
}
export function isBookable(slotStartsAt, now) {
  return now < deadlineOf(slotStartsAt);
}
export function dayRange(dateStr) {
  const start = new Date(`${dateStr}T00:00:00`); // オフセットなし=サーバーのタイムゾーンで解釈
  const end = new Date(start);
  end.setDate(end.getDate() + 1);
  return { start, end };
}

これを、後の章で載せる7つの境界のテストにかけました。日本のタイムゾーンの手元では7件中6件が通り、UTCでは7件すべてが落ちます。

$ IMPL=naive TZ=Asia/Tokyo node --test booking-time.test.mjs
✖ UTC 14:59:59 は日本時間の同じ日、15:00 からは翌日
✔ 前日18時の締め切り:17:59:59 は受け付け、18:00:00 からは締め切り
✔ 朝9時より前の枠(UTC では前の日)でも、前日は日本時間で数える
✔ 月末をまたぐ:11月1日の枠の締め切りは10月31日18時
✔ 年をまたぐ:1月1日の枠の締め切りは12月31日18時
✔ うるう年:2028年3月1日の前日は2月29日、2027年は2月28日
✔ 日本時間の「10月9日の枠」を取る範囲は UTC 10/8 15:00 〜 10/9 15:00
ℹ pass 6
ℹ fail 1

$ IMPL=naive TZ=UTC node --test booking-time.test.mjs
✖ UTC 14:59:59 は日本時間の同じ日、15:00 からは翌日
✖ 前日18時の締め切り:17:59:59 は受け付け、18:00:00 からは締め切り
✖ 朝9時より前の枠(UTC では前の日)でも、前日は日本時間で数える
✖ 月末をまたぐ:11月1日の枠の締め切りは10月31日18時
✖ 年をまたぐ:1月1日の枠の締め切りは12月31日18時
✖ うるう年:2028年3月1日の前日は2月29日、2027年は2月28日
✖ 日本時間の「10月9日の枠」を取る範囲は UTC 10/8 15:00 〜 10/9 15:00
ℹ pass 0
ℹ fail 7

手元で唯一落ちた1件目は、toISOString()がいつもUTCの日付を返すためです。それ以外は手元では通るので、日本で開発しているだけだと気づけません。UTCで落ちた答えをいくつか日本時間に直すと、次のようになっていました。

テスト 正しい締め切り(日本時間) UTCのサーバーでの答え(日本時間)
11月1日10時の枠 10月31日18:00 11月1日3:00(9時間遅い)
10月10日8時30分の枠 10月9日18:00 10月9日3:00(15時間早い)
2028年3月1日8時30分の枠 2028年2月29日18:00 2028年2月29日3:00(15時間早い)

締め切りが遅れれば、準備が間に合わない前夜の予約を受けてしまい、早まれば、受けられるはずの予約を断ることになります。どちらも、画面上はエラーにならずに静かに起きます。

「前日18時締め切り」は、日付にして、暦で1日戻し、+09:00で時刻に戻す

締め切りは、①枠の開始を日本時間の日付(2026-10-10)にし、②暦の上で1日戻し(2026-10-09)、③その日の18:00を+09:00付きの文字列から時刻に戻す、の3段で求めます。日付を出す①はIntl.DateTimeFormatにtimeZone: 'Asia/Tokyo'を指定して行い、②の暦の計算はDate.UTCで年月日だけを扱うので、どちらもサーバーのタイムゾーンに左右されません。

jst.mjsjs
// jst.mjs — DB には UTC の時刻(timestamptz)を置き、日付の判定と表示だけを日本時間で行う
// サーバーの TZ に左右されないよう、Date のローカル時刻のメソッド(getDate・setHours など)は使わない
const TZ = 'Asia/Tokyo';
const JST_OFFSET = '+09:00'; // 日本は現在、夏時間がなく通年 UTC+9

const partsFmt = new Intl.DateTimeFormat('en-US', {
  timeZone: TZ, hourCycle: 'h23',
  year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
});

/** UTC の時刻 → 日本時間の年月日・時分 */
export function jstParts(instant) {
  const p = Object.fromEntries(partsFmt.formatToParts(instant).map(({ type, value }) => [type, value]));
  return { y: +p.year, m: +p.month, d: +p.day, hh: +p.hour, mm: +p.minute };
}

/** UTC の時刻 → 日本時間の日付 'YYYY-MM-DD' */
export function dateKey(instant) {
  const { y, m, d } = jstParts(instant);
  return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
}

/** 'YYYY-MM-DD' に n 日足す(暦の計算だけ。タイムゾーンは関係しない) */
export function addDays(dateStr, n) {
  const [y, m, d] = dateStr.split('-').map(Number);
  return new Date(Date.UTC(y, m - 1, d + n)).toISOString().slice(0, 10);
}

/** 日本時間の日付と時刻 → UTC の時刻 */
export function jstToInstant(dateStr, time = '00:00') {
  return new Date(`${dateStr}T${time}:00${JST_OFFSET}`);
}

/** 「前日18時(日本時間)締め切り」 */
export function deadlineOf(slotStartsAt, { daysBefore = 1, at = '18:00' } = {}) {
  return jstToInstant(addDays(dateKey(slotStartsAt), -daysBefore), at);
}

/** 締め切りの時刻ちょうどからは受け付けない */
export function isBookable(slotStartsAt, now) {
  return now.getTime() < deadlineOf(slotStartsAt).getTime();
}

/** 日本時間のその日の枠を DB から取る範囲 [start, end) */
export function dayRange(dateStr) {
  return { start: jstToInstant(dateStr), end: jstToInstant(addDays(dateStr, 1)) };
}

/** 画面に出す文字列 */
const displayFmt = new Intl.DateTimeFormat('ja-JP', {
  timeZone: TZ, month: 'numeric', day: 'numeric', weekday: 'short', hour: '2-digit', minute: '2-digit',
});
export const formatJst = (instant) => displayFmt.format(instant);

③で+09:00を決め打ちしているのは、日本に現在、夏時間が無く、通年UTC+9だからです。海外の店舗も扱うなど、夏時間のある地域が入るなら、この決め打ちは使えません。その場合は、次の章のTemporalか、タイムゾーンを扱えるライブラリで、地域ごとの時刻からUTCに戻します。

締め切りちょうどの扱いは、now < deadline、つまり18:00:00になった時点で締め切りにしました。どちらにするかは仕様で決め、画面の「前日18時まで」という文言と、テストの18:00:00の期待値をそろえておきます。

予約の空き枠そのものの数え方(スタッフのシフトや休憩との関係)は、パーソナルトレーナーのシフトと予約枠を連動させる記事に書いています。この記事は、その枠を日付と時刻で正しく扱う部分だけを扱います。

その日の枠は、日本時間の0時をUTCに直した範囲で引く

「日本時間の10月9日の枠」をDBから取るときは、日本時間の10月9日0時と10月10日0時をUTCに直し、starts_at >= 開始 AND starts_at < 終わりの範囲で引きます。starts_at::date = '2026-10-09'のように列を日付に変換する書き方は、接続のタイムゾーンの設定で答えが変わります。

PGliteで、日本時間の10月9日0時ちょうど・8時30分・23時30分と、10月10日0時ちょうどの4つの枠を入れて確かめました。接続のタイムゾーンは、クラウドの既定によくあるUTCにしています。

== TZ=UTC
PostgreSQL 18.3 (PGlite 0.5.8) on wasm32-unknown-emscripten, compiled by emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 3.1.74 (1092ec30a3fb1d46b1782ff1b4db5094d3d06ae5), 32-bit
範囲で取った10/9の枠: [
  '1:2026-10-08T15:00:00.000Z',
  '2:2026-10-08T23:30:00.000Z',
  '3:2026-10-09T14:30:00.000Z'
]
悪い例 starts_at::date(セッションのTZ=UTCで日付にする): [ 3, 4 ]
日本時間の日付で集計: [ '2026-10-09=3', '2026-10-10=1' ]
== TZ=Asia/Tokyo
PostgreSQL 18.3 (PGlite 0.5.8) on wasm32-unknown-emscripten, compiled by emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 3.1.74 (1092ec30a3fb1d46b1782ff1b4db5094d3d06ae5), 32-bit
範囲で取った10/9の枠: [
  '1:2026-10-08T15:00:00.000Z',
  '2:2026-10-08T23:30:00.000Z',
  '3:2026-10-09T14:30:00.000Z'
]
悪い例 starts_at::date(セッションのTZ=UTCで日付にする): [ 3, 4 ]
日本時間の日付で集計: [ '2026-10-09=3', '2026-10-10=1' ]

範囲で引いた結果は、10月9日の3つの枠(1〜3)で、10月10日0時ちょうどの枠(4)は入りませんでした。::dateで比べた悪い例では、日本時間で10月9日23時30分の枠(3)と、10月10日0時の枠(4)が返り、10月9日の朝までの2つの枠が抜けています。日本時間の日付ごとに数えたいときは、AT TIME ZONE 'Asia/Tokyo'で日本の時計の時刻にしてから日付にします。TZをUTCと日本時間に変えても、結果は同じでした。

範囲の両端は[開始, 終わり)、つまり開始を含み、終わりを含まない形にそろえます。終わりを23:59:59にすると、23:59:59.5のような端数のある時刻が漏れます。

Temporal・Intl・date-fns系は、動かす場所の対応状況で選ぶ

日本時間だけを扱うなら、追加のライブラリなしで、Intl.DateTimeFormatとDate.UTCで足ります。夏時間のある地域も扱う、日付の計算が多い、といった場合はTemporalが本命ですが、動かす場所が対応しているかを先に確かめます。

TemporalのTC39の提案のページでは、2026年10月4日の時点でStage 4(仕様に入ることが決まった段階)と書かれ(TC39の完了済みの提案の一覧では、2027年版の仕様に入る予定)、Firefox 139、Chrome 144、Node.js 26で組み込まれたと説明されています。MDNの互換性データ(2026年10月1日更新)を見ると、対応は次のとおりでした。

動かす場所 Temporal 補足
Chrome・Edge 144から
Firefox 139から
Safari(Mac) 技術プレビューのみ 正式版は未対応
Safari(iPhone・iPad) 未対応
Node.js 26から 手元のNode.js 22.23.2ではtypeof Temporalがundefined
Deno 2.7から

予約の画面はスマホで開かれることが多く、iPhoneのSafariが未対応のままでは、ブラウザ側でTemporalを前提にできません。使うならポリフィルを読み込みます。サーバー側も、Node.js 22や24で動かしているなら、同じくポリフィルが必要です。

選び方の目安をまとめます。

方法 向いている場面 気をつけること
Intl.DateTimeFormat+Date.UTC 日本時間だけ。依存を増やしたくない getDate()などのローカルのメソッドを混ぜない
Temporal(組み込み、またはポリフィル) 夏時間のある地域も扱う。日付の計算が多い ブラウザとNode.jsの対応状況。ポリフィルの大きさ
date-fns+@date-fns/tz、date-fns-tz すでにdate-fnsを使っている この記事では動かしていない。タイムゾーンの指定の仕方は各ライブラリの説明を確認する

同じ判定をTemporalで書くと、次のようになります。手元のNode.js 22にはTemporalが無いので、temporal-polyfill(1.0.5)を入れて、同じテストを流しました。

temporal.mjsjs
// temporal.mjs — 同じ判定を Temporal で書いた版(Node 26 以降は組み込み。ここではポリフィルで確認)
import { Temporal } from 'temporal-polyfill';
const TZ = 'Asia/Tokyo';
const toZdt = (instant) => Temporal.Instant.fromEpochMilliseconds(instant.getTime()).toZonedDateTimeISO(TZ);
const toDate = (zdt) => new Date(zdt.epochMilliseconds);

export const dateKey = (instant) => toZdt(instant).toPlainDate().toString();

export function deadlineOf(slotStartsAt, { daysBefore = 1, at = '18:00' } = {}) {
  return toDate(
    toZdt(slotStartsAt).toPlainDate()
      .subtract({ days: daysBefore })
      .toZonedDateTime({ timeZone: TZ, plainTime: Temporal.PlainTime.from(at) }),
  );
}

export const isBookable = (slotStartsAt, now) => now.getTime() < deadlineOf(slotStartsAt).getTime();

export function dayRange(dateStr) {
  const day = Temporal.PlainDate.from(dateStr);
  return {
    start: toDate(day.toZonedDateTime(TZ)),                       // その日の最初の時刻
    end: toDate(day.add({ days: 1 }).toZonedDateTime(TZ)),
  };
}
$ IMPL=temporal TZ=UTC node --test booking-time.test.mjs
ℹ pass 7
ℹ fail 0

$ IMPL=temporal TZ=America/Los_Angeles node --test booking-time.test.mjs
ℹ pass 7
ℹ fail 0

PlainDate(日付だけ)、ZonedDateTime(タイムゾーン付きの日時)、Instant(瞬間)を型で分けて書けるので、「日付にして、1日戻し、時刻に戻す」という手順がそのままコードに表れます。Dateを使った版より、どこで日本時間を使っているかが読み取りやすくなります。

境界のテストは、タイムゾーンを変えて流す

日付と時刻のテストは、「いつ流しても」「どこで流しても」同じ結果になるように、時刻をすべてオフセット付きの文字列で書き、TZを変えて同じテストを流します。テストがnew Date()(今の時刻)を使うと、流した日によって結果が変わるので、判定する関数は「今」を引数で受け取る形にしています。

固定した境界は、次の7つです。

テスト ねらい
UTC 14:59:59 と 15:00:00 日本時間の日付が変わる瞬間
前日17:59:59 と 18:00:00 締め切りちょうどの扱い
日本時間8時30分の枠 UTCでは前の日になる枠の「前日」
11月1日の枠 月末をまたぐ
1月1日の枠 年をまたぐ
2028年3月1日と2027年3月1日の枠 うるう年とそうでない年の2月末
日本時間10月9日の範囲 DBから引く範囲の両端
booking-time.test.mjsjs
// booking-time.test.mjs — 境界を固定したテスト。IMPL=naive で古い書き方を試す
import { test } from 'node:test';
import assert from 'node:assert/strict';

const impl = await import(({ naive: './naive.mjs', temporal: './temporal.mjs' })[process.env.IMPL] ?? './jst.mjs');
const T = (iso) => new Date(iso); // テストの時刻は必ずオフセット付きで書く

test('UTC 14:59:59 は日本時間の同じ日、15:00 からは翌日', () => {
  assert.equal(impl.dateKey(T('2026-10-08T14:59:59Z')), '2026-10-08');
  assert.equal(impl.dateKey(T('2026-10-08T15:00:00Z')), '2026-10-09');
});

test('前日18時の締め切り:17:59:59 は受け付け、18:00:00 からは締め切り', () => {
  const slot = T('2026-10-10T11:00:00+09:00');
  assert.equal(impl.isBookable(slot, T('2026-10-09T17:59:59+09:00')), true);
  assert.equal(impl.isBookable(slot, T('2026-10-09T18:00:00+09:00')), false);
});

test('朝9時より前の枠(UTC では前の日)でも、前日は日本時間で数える', () => {
  const slot = T('2026-10-10T08:30:00+09:00'); // = 2026-10-09T23:30Z
  assert.equal(impl.deadlineOf(slot).toISOString(), T('2026-10-09T18:00:00+09:00').toISOString());
});

test('月末をまたぐ:11月1日の枠の締め切りは10月31日18時', () => {
  const slot = T('2026-11-01T10:00:00+09:00');
  assert.equal(impl.deadlineOf(slot).toISOString(), T('2026-10-31T18:00:00+09:00').toISOString());
});

test('年をまたぐ:1月1日の枠の締め切りは12月31日18時', () => {
  const slot = T('2027-01-01T10:00:00+09:00');
  assert.equal(impl.deadlineOf(slot).toISOString(), T('2026-12-31T18:00:00+09:00').toISOString());
});

test('うるう年:2028年3月1日の前日は2月29日、2027年は2月28日', () => {
  assert.equal(impl.deadlineOf(T('2028-03-01T08:30:00+09:00')).toISOString(), T('2028-02-29T18:00:00+09:00').toISOString());
  assert.equal(impl.deadlineOf(T('2027-03-01T08:30:00+09:00')).toISOString(), T('2027-02-28T18:00:00+09:00').toISOString());
});

test('日本時間の「10月9日の枠」を取る範囲は UTC 10/8 15:00 〜 10/9 15:00', () => {
  const { start, end } = impl.dayRange('2026-10-09');
  assert.equal(start.toISOString(), '2026-10-08T15:00:00.000Z');
  assert.equal(end.toISOString(), '2026-10-09T15:00:00.000Z');
});

表示の関数も、サーバーのタイムゾーンに関係なく日本時間で出ることを確かめました。

display.test.mjsjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { formatJst } from './jst.mjs';
test('表示は日本時間(サーバーの TZ に関係なく)', () => {
  assert.equal(formatJst(new Date('2026-10-09T23:30:00Z')), '10/10(土) 08:30');
});

jst.mjsを3つのタイムゾーンで流した結果です。どれも7件すべて通りました。表示のテストも、TZ=UTCで通っています。

$ TZ=UTC node --test booking-time.test.mjs
✔ UTC 14:59:59 は日本時間の同じ日、15:00 からは翌日
✔ 前日18時の締め切り:17:59:59 は受け付け、18:00:00 からは締め切り
✔ 朝9時より前の枠(UTC では前の日)でも、前日は日本時間で数える
✔ 月末をまたぐ:11月1日の枠の締め切りは10月31日18時
✔ 年をまたぐ:1月1日の枠の締め切りは12月31日18時
✔ うるう年:2028年3月1日の前日は2月29日、2027年は2月28日
✔ 日本時間の「10月9日の枠」を取る範囲は UTC 10/8 15:00 〜 10/9 15:00
ℹ pass 7
ℹ fail 0

$ TZ=Asia/Tokyo node --test booking-time.test.mjs
ℹ pass 7
ℹ fail 0

$ TZ=America/Los_Angeles node --test booking-time.test.mjs
ℹ pass 7
ℹ fail 0

CIでは、package.jsonのテストのコマンドを、TZを変えて2回流す形にしておくと、日本で書いたコードがUTCのサーバーで壊れることを、マージの前に見つけられます。

package.json(抜粋)json
{
  "scripts": {
    "test": "TZ=UTC node --test && TZ=America/Los_Angeles node --test"
  }
}

手元でこのコマンド(npm test)を流すと、境界の7件と表示の1件を合わせた8件が、2回とも通りました。

DBの確認に使ったコード

PGliteで、範囲で引く書き方と::dateで比べる書き方、日本時間の日付ごとの集計を比べたスクリプトです。

db-check.mjsjs
// db-check.mjs — PostgreSQL(PGlite)で、timestamptz に入れた時刻と日本時間の日付の扱いを確かめる
import { PGlite } from '@electric-sql/pglite';
import { dayRange } from './jst.mjs';

const db = new PGlite();
console.log((await db.query('SELECT version()')).rows[0].version);
await db.exec(`
  SET TIME ZONE 'UTC';  -- サーバーやクラウドの既定が UTC の想定
  CREATE TABLE slots (
    id        serial PRIMARY KEY,
    starts_at timestamptz NOT NULL,   -- 時刻そのもの(内部は UTC)
    capacity  int NOT NULL
  );
  INSERT INTO slots (starts_at, capacity) VALUES
    ('2026-10-09 00:00:00+09', 2),     -- 日本時間 10/9 の0時ちょうど
    ('2026-10-09 08:30:00+09', 2),     -- UTC ではまだ 10/8
    ('2026-10-09 23:30:00+09', 2),
    ('2026-10-10 00:00:00+09', 2);     -- 翌日。含めてはいけない
`);

const { start, end } = dayRange('2026-10-09');
const r1 = await db.query(
  `SELECT id, starts_at FROM slots WHERE starts_at >= $1 AND starts_at < $2 ORDER BY starts_at`,
  [start.toISOString(), end.toISOString()],
);
console.log('範囲で取った10/9の枠:', r1.rows.map((r) => `${r.id}:${r.starts_at.toISOString()}`));

const r2 = await db.query(
  `SELECT id FROM slots WHERE starts_at::date = '2026-10-09' ORDER BY starts_at`,
);
console.log('悪い例 starts_at::date(セッションのTZ=UTCで日付にする):', r2.rows.map((r) => r.id));

const r3 = await db.query(`
  SELECT (starts_at AT TIME ZONE 'Asia/Tokyo')::date::text AS jst_date, count(*)::int AS n
    FROM slots GROUP BY 1 ORDER BY 1`);
console.log('日本時間の日付で集計:', r3.rows.map((r) => `${r.jst_date}=${r.n}`));

予約の仕組みは、画面よりも、こうした日付や締め切りの決まりのほうが後から直しにくい部分です。管理画面やデータベースをどこに置くかから考えるなら、会社のコラム小さな会社の管理画面を、サーバーを持たずに作る構成も参考になります。株式会社bundlyzeでは、予約管理システムの企画から公開後の運用までを手がけています。締め切りや休業日のような決まりを、テストで固定するところから相談できます。相談の入口はシステム開発のページです。

動作確認した環境

確認日は2026年10月4日、使ったのは手元のMacです。

項目 バージョン・内容
OS macOS 26(Darwin 25.6.0)。システムのタイムゾーンはAsia/Tokyo
Node.js 22.23.2(ICU 78.2、タイムゾーンのデータ 2026a)。テストは標準のnode:test
TZ UTC・Asia/Tokyo・America/Los_Angelesに変えて実行
PostgreSQL PGlite 0.5.8(PostgreSQL 18.3)。接続のタイムゾーンはUTC
Temporal temporal-polyfill 1.0.5(Node.js 22には組み込まれていないため)

確かめていないことは次のとおりです。本物のPostgreSQLのサーバーやSupabase、Cloudflare WorkersなどのUTCで動く本番の環境では動かしていません。Temporalは、組み込みのもの(Chrome 144以降やNode.js 26)では試しておらず、ポリフィルでだけ確かめました。date-fnsと@date-fns/tz、date-fns-tzは、この記事では動かしていません。ブラウザでの表示(formatJstの結果)も、Node.jsでだけ確かめています。

参照した公式ドキュメント(確認日:2026年10月4日)

  • PostgreSQL「8.5. Date/Time Types」:timestamptzが入力をUTCに直して保存すること、出力は接続のタイムゾーンに直されること、AT TIME ZONE
  • MDN「Date」:ローカルのタイムゾーンは動いている環境で決まること、オフセットの無い日時の文字列はローカル時刻、日付だけの文字列はUTCとして解釈されること、toISOString()がいつもUTCを返すこと
  • MDN「Intl.DateTimeFormat() コンストラクター」:timeZoneオプションにIANAのタイムゾーン名を指定できること、既定は動いている環境のタイムゾーンであること
  • MDN「Temporal」:Instant・ZonedDateTime・PlainDateの違い、主要なブラウザで使えるとは言えない状態であること
  • TC39「proposal-temporal」:Stage 4であること、Firefox 139・Chrome 144・Node.js 26で組み込まれたこと
  • MDN「browser-compat-data」:Temporalの対応状況(バージョン 8.1.4、2026年10月1日更新のデータ)
  • Node.js「Command-line API:TZ」:環境変数TZでタイムゾーンを指定できること
  • Node.js「Test runner」:node:testが安定版であること

よくある質問

予約の日時は、DBに日本時間のまま保存してはいけませんか?

「枠の開始」のように時刻そのものを表す値は、PostgreSQLならtimestamptzで持つのがおすすめです。timestamptzは中ではUTCで保存され、取り出すときに指定したタイムゾーンへ直せるので、サーバーやDBの設定が変わっても値の意味が変わりません。一方で、休業日や営業日のように「日本の暦の日付」そのものを表す値は、時刻ではなく日付の型(date)で持つほうが素直です。

日本時間の計算なら、9時間足すだけで十分ではありませんか?

日本は現在、夏時間がなく1年中UTC+9なので、UTCと日本時間の変換だけなら9時間のずれで計算できます。ただし、そのあと日付や時刻を取り出すときに、getDate()やsetHours()のようなサーバーのタイムゾーンを使うメソッドを混ぜると、結果がサーバーの設定に左右されます。この記事では、取り出しをIntl.DateTimeFormatのtimeZone指定に任せ、暦の計算はDate.UTCで行うようにしています。

Temporalは、もう使ってよいですか?

2026年10月4日に確かめた時点で、TemporalはTC39のStage 4で、Chrome 144・Firefox 139・Node.js 26から組み込まれています。一方で、MDNの互換性データではSafariは技術プレビューのみ、iOSのSafariは未対応で、Node.js 22にも入っていません。スマホのブラウザで使うならポリフィルが必要で、サーバー側もNode.jsのバージョン次第です。

締め切りの18時ちょうどに送られた予約は、受け付けますか?

どちらにするかを先に決め、画面の文言とテストをそろえます。この記事の実装では、18:00:00になった時点で締め切り(17:59:59までは受け付け)にしています。画面に「18時まで」と書くなら、受け付ける側の時刻と食い違わないよう、テストで18:00:00ちょうどの扱いを固定しておきます。