第1回では、スペースの一覧と詳細ページを作りました。第2回では、予約サービスの核になる 空き状況カレンダー を作ります。
「空き状況の表示なんて、予約の一覧を並べるだけでしょ? 日付がずれたり、同じ時間が二重に出たりしないか心配…」
結論から言うと、空き状況の計算は「日本時間の1日を、1時間ごとの枠に切る」と決めてしまえば、小さな純粋関数で安全に書けます。 難しいのは画面ではなく「時刻の扱い」です。サーバーが海外にあっても、予約が日付をまたいでも、表示が1日ずれないようにする必要があります。この回では、時間枠の考え方・日本時間(JST)の扱い・予約済みの探し方を、テストつきで整理します。
この回で作るもの
| 作るもの | 場所 | 役割 |
|---|---|---|
| 予約の表 | lib/schema.ts | 開始・終了の時刻をスペースごとに保存 |
| 時間枠の計算 | lib/slots.ts | 1日を1時間枠に切り、予約済み・受付終了を判定 |
| 予約の検索 | lib/availability.ts | そのスペースのその日の予約をDBから取る |
| カレンダー部品 | components/day-calendar.tsx | 前の日・次の日の切り替えと枠の表示 |
| 詳細ページの更新 | app/spaces/[slug]/page.tsx | URLの ?date= で日付を受け取る |
検証環境は Node.js 22系・PostgreSQL 16・Next.js 16・React 19 です(執筆時点の2026年10月)。予約の「受付」(フォーム)は第3回、二重予約の防止は第4回で作ります。この回は「見せる」ことだけに集中します。
時間枠の考え方:先にルールを決める
カレンダーを作る前に、次の4つを決めておきます。後から変えると、データも画面も作り直しになりやすい項目です。
| 決めること | この連載の答え | 理由 |
|---|---|---|
| 枠の長さ | 1時間 | 貸し会議室では一般的。30分刻みにすると枠が倍になる |
| 営業時間 | 9:00〜21:00(最後の枠は20:00〜21:00) | 設定1か所で変えられるよう定数にする |
| 時刻の基準 | 日本時間(JST、UTC+9) | 日本のスペースだけを扱う前提。夏時間が無いので計算が単純 |
| 予約を受ける期間 | 今日から30日先まで | 遠すぎる日付の予約や、過去日への予約を防ぐ |
特に大事なのは「重なりの判定」です。10時〜11時の予約と、11時〜12時の予約は両立する(終わりと始まりがちょうど同じ)と決めます。この「ぴったり同じは重ならない」ルールをコードに書き、テストで固定します。
予約の表を追加する
空き状況を出すには、予約を保存する表が要ります。第1回の spaces に続けて、reservations を足します。
// lib/schema.ts(追加分)
export const reservations = pgTable(
"reservations",
{
id: serial("id").primaryKey(),
spaceId: integer("space_id")
.notNull()
.references(() => spaces.id, { onDelete: "cascade" }),
startsAt: timestamp("starts_at", { withTimezone: true }).notNull(),
endsAt: timestamp("ends_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
// 「このスペースの、この日の予約」を速く探すための索引
(t) => [index("reservations_space_starts_idx").on(t.spaceId, t.startsAt)],
);
ポイントは3つです。
- 時刻は
timestamp with time zone(タイムゾーンつき)で持つ:PostgreSQL は内部ではUTC(世界標準時)で保存し、取り出すときに設定されたタイムゾーンへ変換します。「日本の10時」という意味を失わずに保存できます。 - 開始と終了の2列で持つ:「10時から2時間」ではなく「10時〜12時」と持つと、重なりの判定が単純になります。
- 索引(index)を付ける:索引とは、本の索引のように探す速度を上げる仕組みです。予約が増えても、「このスペースの今日の予約」を素早く探せます。
表の追加は、第1回と同じマイグレーションで行います。
npm run db:generate # drizzle/0001_*.sql が作られる
npm run db:migrate # データベースに適用する
📰 出典:Drizzle ORM Docs「Indexes & Constraints」
日本時間の扱いを関数にまとめる
ここが今回の山場です。サーバーのタイムゾーンは環境によって異なります(クラウドでは UTC が多いです)。そのため、new Date() から「今日の日付」を取り出す処理を安易に書くと、日本時間の朝9時までは「昨日」になってしまう不具合が起きます。
そこで、日付と時刻の計算は lib/slots.ts に集め、日本時間を明示した関数だけを使います。
// lib/slots.ts(抜粋)
const JST_OFFSET_HOURS = 9; // 日本は夏時間が無いので、常に UTC+9
const HOUR_MS = 60 * 60 * 1000;
// 「日本の日付 + 時刻」から、世界共通の瞬間(Date)を作る
export function jstDateTime(date: string, hour: number): Date {
return new Date(Date.parse(`${date}T00:00:00Z`) + (hour - JST_OFFSET_HOURS) * HOUR_MS);
}
// 今この瞬間が、日本では何月何日か。サーバーが UTC で動いていても結果は同じ
export function todayInJst(now: Date = new Date()): string {
return new Date(now.getTime() + JST_OFFSET_HOURS * HOUR_MS).toISOString().slice(0, 10);
}
考え方はシンプルです。日付は “2026-10-12” のような文字列で持ち、DBに問い合わせる瞬間だけ Date に変換する。こうすると、サーバーの設定に左右されません。たとえば jstDateTime("2026-10-12", 9) は UTC の 2026-10-12 0:00 になります(日本の9時=UTCの0時)。
URLから受け取る日付も、そのまま信用しません。存在しない日付や範囲外は「今日」に戻します。
// lib/slots.ts(抜粋)
export const BOOKING_WINDOW_DAYS = 30; // 予約を受け付けるのは本日から30日先まで
// "2026-02-31" のような存在しない日付は undefined
export function parseDateParam(value: string | undefined): string | undefined {
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined;
const d = new Date(`${value}T00:00:00Z`);
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== value) return undefined;
return value;
}
// 不正な値・範囲外は「今日」に戻す
export function resolveDate(param: string | undefined, now: Date = new Date()) {
const today = todayInJst(now);
const maxDate = addDays(today, BOOKING_WINDOW_DAYS);
const parsed = parseDateParam(param);
const date = parsed && parsed >= today && parsed <= maxDate ? parsed : today;
return { date, today, maxDate };
}
📰 出典:PostgreSQL 16 Documentation「Date/Time Types」
1日を1時間枠に切って、状態を付ける
次に、その日の枠を作ります。各枠は「空き」「予約済み」「受付終了(すでに始まっている)」のどれかです。
// lib/slots.ts(抜粋)
// 2つの時間帯が重なるか。「終わりと始まりがちょうど同じ」は重ならない
export function overlaps(a: TimeRange, b: TimeRange): boolean {
return a.startsAt < b.endsAt && b.startsAt < a.endsAt;
}
// その日の 1 時間ごとの枠を作り、予約済み・過去の枠に印を付ける
export function buildDaySlots(date: string, booked: TimeRange[], now: Date = new Date()): Slot[] {
const slots: Slot[] = [];
for (let hour = OPEN_HOUR; hour < CLOSE_HOUR; hour++) {
const range = { startsAt: jstDateTime(date, hour), endsAt: jstDateTime(date, hour + 1) };
const status: SlotStatus = range.startsAt <= now
? "past"
: booked.some((b) => overlaps(range, b))
? "booked"
: "available";
slots.push({ ...range, label: `${String(hour).padStart(2, "0")}:00`, status });
}
return slots;
}
重なりの判定 a.startsAt < b.endsAt && b.startsAt < a.endsAt は、予約システムで最もよく使う式です。「Aが始まる前にBが終わっていて、Bが始まる前にAが終わっている」のどちらでもなければ重なる、という考え方で、< と <= を間違えなければ「ぴったり隣」も正しく扱えます。
buildDaySlots は DB を呼ばず、now(現在時刻)も引数で受け取ります。だから「今が10時なら9時と10時の枠は受付終了」といった確認を、DBなしで何度でもテストできます。
DBから、その日の予約だけを取り出す
予約を探す関数は、スペースと日付を受け取ります。日をまたぐ予約(例: 20時〜翌2時)も拾えるよう、「開始 < その日の終わり」かつ「終了 > その日の始まり」で探します。
// lib/availability.ts
export async function listReservedRanges(spaceId: number, date: string): Promise<TimeRange[]> {
const day = jstDayRange(date);
return getDb()
.select({ startsAt: reservations.startsAt, endsAt: reservations.endsAt })
.from(reservations)
.where(
and(
eq(reservations.spaceId, spaceId),
lt(reservations.startsAt, day.endsAt),
gt(reservations.endsAt, day.startsAt),
),
)
.orderBy(asc(reservations.startsAt));
}
詳細ページに日付つきで組み込む
詳細ページは、URLの ?date=2026-10-13 を受け取って表示する日を決めます。Next.js 16 では、params と同じく searchParams も Promise なので await します。
// app/spaces/[slug]/page.tsx(抜粋)
type Props = {
params: Promise<{ slug: string }>;
searchParams: Promise<{ date?: string }>;
};
export default async function SpaceDetailPage({ params, searchParams }: Props) {
const { slug } = await params;
const { date: dateParam } = await searchParams;
const space = await findSpaceBySlug(slug);
if (!space) notFound();
const { date, today, maxDate } = resolveDate(dateParam);
const booked = await listReservedRanges(space.id, date);
const slots = buildDaySlots(date, booked);
// ...(第1回の表示のあとに)
// <DayCalendar slug={space.slug} date={date} today={today} maxDate={maxDate} slots={slots} />
}
日付の切り替えは、フォームやJavaScriptを使わず、?date= を変えた普通のリンクにしています。
// components/day-calendar.tsx(抜粋)
{date > today ? <Link href={`/spaces/${slug}?date=${prev}`}>← 前の日</Link> : <span>← 前の日</span>}
{date < maxDate ? <Link href={`/spaces/${slug}?date=${next}`}>次の日 →</Link> : <span>次の日 →</span>}
リンクにしておくと、特定の日の空き状況をURLでそのまま共有でき、ブラウザの「戻る」も自然に動きます。予約できない範囲では、リンクではなく文字だけにしています。
📰 出典:Next.js Docs「page.js」(searchParams)
テストで「ずれ」を固定する
時刻のバグは目で見て気づきにくいため、テストで固定します。次は tests/slots.test.ts の一部です。
// tests/slots.test.ts(抜粋)
it("UTC では前日でも、日本ではその日になる", () => {
expect(todayInJst(new Date("2026-10-11T15:30:00Z"))).toBe("2026-10-12");
expect(todayInJst(new Date("2026-10-11T14:59:00Z"))).toBe("2026-10-11");
});
it("終わりと始まりがぴったり同じなら重ならない", () => {
expect(overlaps(r(10, 11), r(11, 12))).toBe(false);
});
it("日をまたぐ予約でも前日の夜の枠が埋まる", () => {
const booked = [{ startsAt: jstDateTime(day, 20), endsAt: jstDateTime(day, 30) }];
expect(buildDaySlots(day, booked, now).at(-1)?.status).toBe("booked");
});
1つ目は、UTC の 15:00 を境に日本の日付が変わることを確かめています。サーバーのタイムゾーンを変えても結果が変わらないことが、この回の最大のポイントです。DB に接続できる環境では、「指定した日の予約だけが返り、別の日の予約は含まれない」ことも実際の PostgreSQL で確認します。
動かして確認する
開発用データは、「今日から見て相対的な日付」で予約を入れています(明日の10〜12時など)。実行した日に関係なく、空き状況の見た目を確かめられます。
export DATABASE_URL=postgres://yoyaku:yoyaku@localhost:5432/yoyaku
npm run db:migrate && npm run db:seed
npm run build && npm start
検証環境で次のとおり確認できました。
| 確認 | 結果 |
|---|---|
/spaces/shibuya-a(日付なし) | 200。今日の枠が表示され、始まっている時間は「受付終了」 |
/spaces/shibuya-a?date=(明日) | 200。10〜11時台と14時台が「予約済み」、ほかは「空き」 |
/spaces/shibuya-a?date=zzz(不正な日付) | 200。今日の表示に戻る |
/spaces/nope(存在しないスペース) | 404 |
npm run lint・npx tsc --noEmit・npm run build | 成功 |
npm test | 22件成功(DBに接続できるときは、予約の検索も実際のDBで確認) |
※ 画面はまだ装飾(CSS)を付けていません。次の画像は開発環境(Linux)での確認画面です。


つまずきやすい点
- 日付が1日ずれる:サーバーのタイムゾーンに頼った
new Date()とtoLocaleDateString()の組み合わせが原因になりやすいです。日付の計算は、日本時間を明示した関数にまとめましょう。 - 「ぴったり隣」の予約が弾かれる:重なりの判定で
<=と<を取り違えると、10〜11時の次に11〜12時を予約できなくなります。テストで固定しておくと安心です。 searchParamsをawaitし忘れる:Next.js 16 では Promise です。npm run typecheckで気づけます。- 画面の表示を信じすぎる:この回の「空き」は、表示の時点の状況にすぎません。表示のあとに別の人が予約することがあるため、予約の確定時にDB側で必ず再確認します(第4回)。
なお、連載では省略するものもあります(スペースごとの営業時間・定休日、30分刻みなど枠の長さの変更、祝日の扱い)。必要になった時点で、lib/slots.ts の定数や関数を拡張します。
発注者向けメモ:空き状況カレンダーを頼むときの確認点
空き状況の表示は簡単に見えますが、「営業のルール」が増えるほど工数が増える機能です。開発会社に頼むときは、次を確認してみてください。
- ☐ 予約の単位(30分・1時間・半日・1日)を決めたか。スペースごとに違うか
- ☐ 営業時間・定休日・祝日・臨時休業を、スペースごとに設定する必要があるか
- ☐ 何日先まで予約を受け付けるか。直前の予約は何時間前まで認めるか
- ☐ 海外からの利用や、複数のタイムゾーンを扱う可能性があるか(あると設計が変わります)
- ☐ 表示した空きと、予約確定時の空きがずれたとき、利用者にどう伝えるか
- ☐ 予約の準備・片付け時間(前後に空ける時間)を枠に含めるか
開発会社への質問例です。
- 「営業時間や定休日を変えたいとき、画面から私たちで設定できますか。それとも開発の依頼が必要ですか」
- 「祝日や臨時休業は、カレンダーにどう反映されますか。見積もりに含まれていますか」
- 「時刻は何を基準に保存していますか。日付が1日ずれないことは、どのように確認していますか」
- 「予約の単位を30分に変えたり、スペースごとに変えたりする場合、見積もりはどう変わりますか」
まとめと次回予告
第2回では、次のことを行いました。
- 予約の表を追加し、時刻をタイムゾーンつきで保存した
- 日本時間の日付・時刻の計算を、サーバーの設定に左右されない関数にまとめた
- 1日を1時間枠に切り、「空き・予約済み・受付終了」を判定した
- URLの
?date=で日付を切り替えられる空き状況カレンダーを作った - 時刻の「ずれ」や「ぴったり隣」を、テストで固定した
次回の第3回では、予約フォーム を Server Actions で作ります。Server Actions とは、API を別に作らずに、フォームの送信先となるサーバー側の処理を書ける Next.js の機能です。入力チェックには Zod を使い、エラーの表示まで進めます。サンプルコード全体は、連載の code/ ディレクトリにまとめています。
この連載の記事一覧
この記事は連載「Next.jsで作るオンライン予約サービス」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【Next.jsで作るオンライン予約サービス 第0回】完成像とNext.js 16の雛形を動かす(App Router・PostgreSQL・Drizzle)
- 【Next.jsで作るオンライン予約サービス 第1回】スペース一覧と詳細ページを作る(Server Components・Drizzle・メタデータ)
- 【Next.jsで作るオンライン予約サービス 第2回】空き状況カレンダーを作る(時間枠・日本時間・Drizzle)(この記事)









