予約の空き枠と締め切りを、日本時間で正しく扱う実装。DBはUTCで保存し、判定と表示はAsia/Tokyoで行う。UTC 15時の日付の境界、「前日18時締め切り」の計算、Temporal・Intl・date-fns系の選び方、境界を固定するテスト
予約の
このtimestamptz)でAsia/Tokyoを+09:00付きでgetDate()やsetHours()は
このnode:testで、TZをUTC・Asia/Tokyo・America/Los_Angelesに
時刻はUTCで保存し、日本時間は判定と表示のときだけ使う
予約の
PostgreSQLのtimestamp with time zone(timestamptz)のTimeZoneの
値の
| 値の例 | 意味 | 持ち方 |
|---|---|---|
| 枠の |
時刻 |
timestamptz(UTC) |
| 休業日・臨時営業日・フェアの |
日本の |
date(タイムゾーンなし) |
| 「毎日 |
日本の |
time(タイムゾーンなし)と、 |
「日付だけの
日付の境界はUTCの15時で、ここで「今日」と「前日」がずれる
日本時間の14:59:59と15:00:00の
2026-10-08T14:59:59Z → 日本時間 2026-10-08 23:59:59
2026-10-08T15:00:00Z → 日本時間 2026-10-09 00:00:00予約で
- 「今日の
枠」の :日本時間の一覧 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()は、
MDNのDateのDateの"2026-10-09T00:00:00"のように"2026-10-09"のようなTZでTZを
よく
// 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 };
}これを、
$ 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手元でtoISOString()が
| テスト | 正しい |
UTCの |
|---|---|---|
| 11月1日10時の |
10月31日18:00 | 11月1日3:00 |
| 10月10日8時30分の |
10月9日18:00 | 10月9日3:00 |
| 2028年3月1日8時30分の |
2028年2月29日18:00 | 2028年2月29日3:00 |
締め切りが
「前日18時締め切り」は、日付にして、暦で1日戻し、+09:00で時刻に戻す
締め切りは、2026-10-10)にし、2026-10-09)、18:00を+09:00付きのIntl.DateTimeFormatにtimeZone: 'Asia/Tokyo'をDate.UTCで
// 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を
締め切りちょうどのnow < deadline、18:00:00の
予約の
その日の枠は、日本時間の0時をUTCに直した範囲で引く
「日本時間のstarts_at >= 開始 AND starts_at < 終わりのstarts_at::date = '2026-10-09'のように
PGliteで、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' ]範囲で::dateでAT TIME ZONE 'Asia/Tokyo'でTZを
範囲の[開始, 終わり)、23:59:59にすると、23:59:59.5のような
Temporal・Intl・date-fns系は、動かす場所の対応状況で選ぶ
日本時間だけをIntl.DateTimeFormatとDate.UTCで
Temporalの
| 動かす場所 | Temporal | 補足 |
|---|---|---|
| Chrome・Edge | 144から | |
| Firefox | 139から | |
| Safari |
技術プレビューのみ | 正式版は |
| Safari |
未対応 | |
| Node.js | 26から | 手元のtypeof Temporalがundefined |
| Deno | 2.7から |
予約の
選び方の
| 方法 | 向いている |
気を |
|---|---|---|
Intl.DateTimeFormat+Date.UTC |
日本時間だけ。 |
getDate()などの |
| Temporal |
夏時間の |
ブラウザと |
date-fns+@date-fns/tz、date-fns-tz |
すでに |
この |
同じtemporal-polyfill(1.0.5)を
// 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 0PlainDate(日付だけ)、ZonedDateTime(タイムゾーン付きのInstant(瞬間)をDateを
境界のテストは、タイムゾーンを変えて流す
日付とTZをnew Date()(今の
固定した
| テスト | ねらい |
|---|---|
| UTC 14:59:59 と |
日本時間の |
| 前日17:59:59 と |
締め切りちょうどの |
| 日本時間8時30分の |
UTCでは |
| 11月1日の |
月末を |
| 1月1日の |
年をまたぐ |
| 2028年3月1日と |
うるう年と |
| 日本時間10月9日の |
DBから |
// 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');
});表示の
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を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 0CIでは、package.jsonのTZを
{
"scripts": {
"test": "TZ=UTC node --test && TZ=America/Los_Angeles node --test"
}
}手元でnpm test)を
DBの確認に使ったコード
PGliteで、::dateで
// 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}`));予約の
動作確認した環境
確認日は
| 項目 | バージョン・内容 |
|---|---|
| OS | macOS 26Asia/Tokyo |
| Node.js | 22.23.2node:test |
| TZ | UTC・Asia/Tokyo・America/Los_Angelesに |
| PostgreSQL | PGlite 0.5.8UTC |
| Temporal | temporal-polyfill 1.0.5 |
確かめていない@date-fns/tz、formatJstの
参照した公式ドキュメント(確認日: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に日本時間のまま保存してはいけませんか?
「枠の
日本時間の計算なら、9時間足すだけで十分ではありませんか?
日本は
Temporalは、もう使ってよいですか?
2026年10月4日に
締め切りの18時ちょうどに送られた予約は、受け付けますか?
どちらに