██╗   ██╗███████╗██████╗ ███████╗██╗   ██╗ ██████╗ ██╗██████╗
██║   ██║██╔════╝██╔══██╗██╔════╝██║   ██║██╔═══██╗██║██╔══██╗
██║   ██║█████╗  ██████╔╝█████╗  ██║   ██║██║   ██║██║██████╔╝
╚██╗ ██╔╝██╔══╝  ██╔══██╗██╔══╝  ╚██╗ ██╔╝██║   ██║██║██╔══██╗
 ╚████╔╝ ███████╗██║  ██║███████╗ ╚████╔╝ ╚██████╔╝██║██║  ██║
  ╚═══╝  ╚══════╝╚═╝  ╚═╝╚══════╝  ╚═══╝   ╚═════╝ ╚═╝╚═╝  ╚═╝

Bookings

@verevoir/bookings provides time-based availability — calendars, rules, computed availability, offerings, holds, and confirmed bookings. Availability is derived from rules minus demand; empty slots are never stored.

Installation

npm install @verevoir/bookings

One runtime dependency: rrule (iCal recurrence expansion). No dependency on any @verevoir/* package.

Overview

Bookings operates on six core concepts:

The core equation: availability = rules(range) - bookings - active holds.

No empty slot records are created or stored. Availability is computed on demand from rules, then existing bookings and unexpired holds are subtracted.

Step 1 — Define Calendars

A calendar represents a bookable resource. It has a slot duration (the granularity of time) and a default capacity per slot:

import { defineCalendar } from '@verevoir/bookings';

// Restaurant: 15-minute slots, 10 tables
const tables = defineCalendar({
  id: 'tables',
  slotDuration: { minutes: 15 },
  defaultCapacity: 10,
});

// Hotel: 1-day slots, 50 rooms
const rooms = defineCalendar({
  id: 'rooms',
  slotDuration: { days: 1 },
  defaultCapacity: 50,
});

// Conference: 1-hour slots, 200 delegates
const mainHall = defineCalendar({
  id: 'main-hall',
  slotDuration: { hours: 1 },
  defaultCapacity: 200,
});

Slot duration determines the granularity of availability. A restaurant books in 15-minute blocks (a 90-minute dinner is 6 contiguous slots). A hotel books in days. Choose the smallest unit your domain needs.

Step 2 — Define Availability Rules

Rules use the iCal RRULE standard to define recurring availability patterns:

import { defineRule } from '@verevoir/bookings';

// Restaurant open Monday to Saturday, 17:00–23:00
const weekdayEvening = defineRule({
  calendarId: 'tables',
  rrule: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA',
  timeRange: { start: '17:00', end: '23:00' },
});

// Sunday lunch only, 12:00–15:00, reduced capacity
const sundayLunch = defineRule({
  calendarId: 'tables',
  rrule: 'FREQ=WEEKLY;BYDAY=SU',
  timeRange: { start: '12:00', end: '15:00' },
  capacity: 6, // override default capacity for this rule
});

// Hotel available every day, all day
const dailyRooms = defineRule({
  calendarId: 'rooms',
  rrule: 'FREQ=DAILY',
  timeRange: { start: '00:00', end: '24:00' },
});

// Conference: specific dates
const conferenceFriday = defineRule({
  calendarId: 'main-hall',
  rrule: 'FREQ=DAILY;COUNT=1',
  timeRange: { start: '09:00', end: '18:00' },
});

Rules are expanded into concrete slots at query time using the rrule library. The capacity field optionally overrides the calendar's defaultCapacity for that rule — useful for reduced capacity on certain days or seasonal variations.

Common RRULE Patterns

PatternRRULE
Every dayFREQ=DAILY
Weekdays onlyFREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR
Every SaturdayFREQ=WEEKLY;BYDAY=SA
First Monday of each monthFREQ=MONTHLY;BYDAY=1MO
Specific date (one-off)FREQ=DAILY;COUNT=1 (with DTSTART in the rule)
Every other weekFREQ=WEEKLY;INTERVAL=2

Step 3 — Compute Availability

Query available slots for a calendar over a date range:

import { computeAvailability } from '@verevoir/bookings';

const slots = computeAvailability(
  tables,                                                    // calendar
  [weekdayEvening, sundayLunch],                             // rules
  { start: new Date('2026-04-06'), end: new Date('2026-04-13') }, // date range
  existingBookings,                                          // confirmed bookings
  activeHolds,                                               // unexpired holds
);

Each returned Slot carries computed capacity:

interface Slot {
  calendarId: string;
  start: Date;
  end: Date;
  capacity: number;   // total capacity from rules
  used: number;       // consumed by bookings + active holds
  available: number;  // capacity - used (floored at 0)
}

Pass empty arrays for bookings and holds if you have none yet:

const slots = computeAvailability(tables, rules, dateRange, [], []);

Contiguous Slots

For bookings that span multiple slots (a 90-minute dinner = 6 × 15-minute slots), find groups of contiguous available slots:

import { findContiguousSlots } from '@verevoir/bookings';

const slots = computeAvailability(tables, rules, dateRange, bookings, holds);

// Find groups of 6 contiguous slots (90 minutes)
const dinnerWindows = findContiguousSlots(slots, 6);

// Each entry is an array of 6 consecutive Slot objects
for (const window of dinnerWindows) {
  const start = window[0].start;  // e.g. 17:00
  const end = window[5].end;      // e.g. 18:30
  console.log(`Available: ${start.toLocaleTimeString()} – ${end.toLocaleTimeString()}`);
}

The customer sees "18:00" as a dinner slot. They don't know it's 6 × 15-minute blocks. The slot granularity is an implementation detail.

An optional third parameter sets the minimum capacity per slot (default 1):

// Only windows where at least 4 tables are free (for a large party)
const largePartyWindows = findContiguousSlots(slots, 6, 4);

Step 4 — Define Offerings

An offering is the customer-facing bookable item. It maps to one or more calendar slots:

import { defineOffering } from '@verevoir/bookings';

// Simple: 90-minute dinner reservation
const dinner = defineOffering({
  id: 'dinner',
  label: 'Dinner Reservation',
  mappings: [{ calendarId: 'tables', slotCount: 6, contiguous: true }],
});

// Simple: 1-night hotel stay
const singleNight = defineOffering({
  id: 'single-night',
  label: 'One Night Stay',
  mappings: [{ calendarId: 'rooms', slotCount: 1 }],
});

Composite Offerings

Some bookings require availability across multiple independent calendars. A theme park visitor might need entrance tickets, parking, and a disability pass — all on the same day:

const entrance = defineCalendar({
  id: 'entrance',
  slotDuration: { days: 1 },
  defaultCapacity: 5000,
});

const parking = defineCalendar({
  id: 'parking',
  slotDuration: { days: 1 },
  defaultCapacity: 2000,
});

const disabilityPass = defineCalendar({
  id: 'disability-pass',
  slotDuration: { days: 1 },
  defaultCapacity: 100,
});

const accessibleDayOut = defineOffering({
  id: 'accessible-day',
  label: 'Accessible Day Visit',
  mappings: [
    { calendarId: 'entrance', slotCount: 1 },
    { calendarId: 'parking', slotCount: 1 },
    { calendarId: 'disability-pass', slotCount: 1 },
  ],
});

Query composite availability — returns only dates where all required calendars have capacity:

import { computeCompositeAvailability } from '@verevoir/bookings';

const available = computeCompositeAvailability(
  [entrance, parking, disabilityPass],  // all calendars involved
  allRules,                              // rules for all calendars
  accessibleDayOut,                      // offering
  { start: new Date('2026-07-01'), end: new Date('2026-07-31') },
  bookings,
  holds,
);

// Each result has a date and per-calendar availability
for (const day of available) {
  const entranceAvail = day.availableByCalendar.get('entrance');
  const parkingAvail = day.availableByCalendar.get('parking');
  const passAvail = day.availableByCalendar.get('disability-pass');
  console.log(`${day.date.toDateString()}: ${entranceAvail} entrance, ${parkingAvail} parking, ${passAvail} passes`);
}

If any calendar has zero capacity on a date, that date is excluded entirely. The customer only sees dates where they can get everything they need.

Conference Bundles

Conference tickets are another composite pattern. A weekend pass requires one slot each on Friday, Saturday, and Sunday — but each day is its own calendar because individual day tickets are also sold:

const friday = defineCalendar({ id: 'friday', slotDuration: { days: 1 }, defaultCapacity: 200 });
const saturday = defineCalendar({ id: 'saturday', slotDuration: { days: 1 }, defaultCapacity: 200 });
const sunday = defineCalendar({ id: 'sunday', slotDuration: { days: 1 }, defaultCapacity: 200 });

const weekendPass = defineOffering({
  id: 'weekend-pass',
  label: 'Weekend Pass',
  mappings: [
    { calendarId: 'friday', slotCount: 1 },
    { calendarId: 'saturday', slotCount: 1 },
    { calendarId: 'sunday', slotCount: 1 },
  ],
});

const saturdayOnly = defineOffering({
  id: 'saturday-ticket',
  label: 'Saturday Only',
  mappings: [{ calendarId: 'saturday', slotCount: 1 }],
});

Weekend passes and individual day tickets share the same calendars. When someone buys a weekend pass, capacity decreases on all three days.

Step 5 — Hold Slots

When a customer selects an available slot, place a hold. This gives them time to complete the purchase without someone else taking the slot:

import { createHold, extendHold, isHoldExpired } from '@verevoir/bookings';

const hold = createHold({
  id: 'hold-1',
  offeringId: 'dinner',
  slots: [
    { calendarId: 'tables', start: new Date('2026-04-07T18:00:00'), end: new Date('2026-04-07T18:15:00'), count: 1 },
    { calendarId: 'tables', start: new Date('2026-04-07T18:15:00'), end: new Date('2026-04-07T18:30:00'), count: 1 },
    { calendarId: 'tables', start: new Date('2026-04-07T18:30:00'), end: new Date('2026-04-07T18:45:00'), count: 1 },
    { calendarId: 'tables', start: new Date('2026-04-07T18:45:00'), end: new Date('2026-04-07T19:00:00'), count: 1 },
    { calendarId: 'tables', start: new Date('2026-04-07T19:00:00'), end: new Date('2026-04-07T19:15:00'), count: 1 },
    { calendarId: 'tables', start: new Date('2026-04-07T19:15:00'), end: new Date('2026-04-07T19:30:00'), count: 1 },
  ],
  heldBy: 'user-123',
  ttl: { minutes: 10 },
});

Holds are subtracted from availability — other customers querying the same time window will see reduced capacity.

Extend on Activity

Reset the TTL when the customer takes action (fills in details, selects extras). This prevents timeouts during an active purchase flow:

// Customer is filling in their details — extend the hold
const extended = extendHold(hold, { minutes: 10 });

The new expiry is calculated from the current time, not from the original hold time. Each interaction buys another 10 minutes.

Check Expiry

if (isHoldExpired(hold)) {
  // Hold has expired — slots are available again
  // Clean up the hold from your data store
}

Expired holds are automatically ignored by computeAvailability — you don't need to delete them for availability to be correct. Clean them up at your own pace.

Step 6 — Confirm Bookings

Convert a hold to a confirmed booking:

import { holdToBooking } from '@verevoir/bookings';

const booking = holdToBooking(hold, {
  id: 'booking-1',
});

The booking inherits the slots and customer from the hold. Bookings are permanent — they reduce availability until explicitly removed from your data store.

Link to Commerce

If you're using @verevoir/commerce, link the booking to an order:

const booking = holdToBooking(hold, {
  id: 'booking-1',
  orderId: 'order-456',
});

The orderId is stored on the booking for your reference. The bookings package does not import or depend on commerce — the consumer wires the two together:

import { convertToOrder, applyPayment } from '@verevoir/commerce';
import { holdToBooking } from '@verevoir/bookings';

// 1. Convert basket to order
const order = convertToOrder(basket, 'order-456');

// 2. Confirm booking linked to order
const booking = holdToBooking(hold, {
  id: 'booking-1',
  orderId: order.id,
});

// 3. Process payment against the order
const paid = applyPayment(order, {
  id: 'pay-1',
  amount: orderTotal,
  status: 'confirmed',
});

Putting It Together

A complete restaurant booking flow:

import {
  defineCalendar,
  defineRule,
  defineOffering,
  computeAvailability,
  findContiguousSlots,
  createHold,
  extendHold,
  holdToBooking,
} from '@verevoir/bookings';

// 1. Define the resource
const tables = defineCalendar({
  id: 'tables',
  slotDuration: { minutes: 15 },
  defaultCapacity: 10,
});

// 2. Define when it's available
const eveningService = defineRule({
  calendarId: 'tables',
  rrule: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA',
  timeRange: { start: '17:00', end: '23:00' },
});

// 3. Define the customer-facing offering
const dinner = defineOffering({
  id: 'dinner',
  label: 'Dinner Reservation (90 min)',
  mappings: [{ calendarId: 'tables', slotCount: 6, contiguous: true }],
});

// 4. Query availability
const dateRange = { start: new Date('2026-04-07'), end: new Date('2026-04-08') };
const slots = computeAvailability(tables, [eveningService], dateRange, bookings, holds);

// 5. Find valid 90-minute windows
const windows = findContiguousSlots(slots, 6);
// Show customer: "18:00", "18:15", "18:30", etc.

// 6. Customer selects 18:00 — hold it
const hold = createHold({
  id: 'hold-abc',
  offeringId: 'dinner',
  slots: windows[0].map((s) => ({
    calendarId: s.calendarId,
    start: s.start,
    end: s.end,
    count: 1,
  })),
  heldBy: 'customer-jane',
  ttl: { minutes: 10 },
});

// 7. Customer fills in details — extend the hold
const extended = extendHold(hold, { minutes: 10 });

// 8. Customer confirms — convert to booking
const booking = holdToBooking(extended, { id: 'booking-xyz' });
// Store the booking — it now reduces availability for future queries

Domain Patterns

Hotel

const rooms = defineCalendar({ id: 'rooms', slotDuration: { days: 1 }, defaultCapacity: 50 });
const daily = defineRule({ calendarId: 'rooms', rrule: 'FREQ=DAILY', timeRange: { start: '00:00', end: '24:00' } });

// 3-night stay = 3 contiguous day slots
const threeNight = defineOffering({
  id: '3-night',
  label: 'Three Night Stay',
  mappings: [{ calendarId: 'rooms', slotCount: 3, contiguous: true }],
});

Skate Park

const park = defineCalendar({ id: 'park', slotDuration: { minutes: 30 }, defaultCapacity: 200 });
const allDay = defineRule({ calendarId: 'park', rrule: 'FREQ=DAILY', timeRange: { start: '09:00', end: '21:00' } });

// Single 30-min session
const session = defineOffering({
  id: 'session',
  label: 'Skate Session',
  mappings: [{ calendarId: 'park', slotCount: 1 }],
});

Theme Park (Composite)

const entrance = defineCalendar({ id: 'entrance', slotDuration: { days: 1 }, defaultCapacity: 5000 });
const premiumParking = defineCalendar({ id: 'premium-parking', slotDuration: { days: 1 }, defaultCapacity: 500 });

const premiumVisit = defineOffering({
  id: 'premium-visit',
  label: 'Premium Day Visit',
  mappings: [
    { calendarId: 'entrance', slotCount: 1 },
    { calendarId: 'premium-parking', slotCount: 1 },
  ],
});

// Only shows dates where both entrance AND premium parking are available
const dates = computeCompositeAvailability(
  [entrance, premiumParking],
  allRules,
  premiumVisit,
  dateRange,
  bookings,
  holds,
);

Persistence

The bookings package is pure computation — it has no I/O, no database, and no persistence. You store calendars, rules, holds, and bookings in whatever database you choose. Pass them to the computation functions when querying availability.

A typical pattern with @verevoir/storage:

import { MemoryAdapter } from '@verevoir/storage';

const storage = new MemoryAdapter();

// Store a booking after confirmation
await storage.create('booking', {
  offeringId: booking.offeringId,
  slots: booking.slots.map((s) => ({
    calendarId: s.calendarId,
    start: s.start.toISOString(),
    end: s.end.toISOString(),
    count: s.count,
  })),
  bookedBy: booking.bookedBy,
  bookedAt: booking.bookedAt.toISOString(),
  orderId: booking.orderId,
});

// Load bookings for availability queries
const docs = await storage.list('booking');
const bookings = docs.map((doc) => toBooking(doc));

Concurrency control (preventing double-booking under load) is a service-layer concern. For high-traffic systems, a reference service using PostgreSQL FOR UPDATE SKIP LOCKED and advisory locks is the recommended approach.

Further Reading