Skip to content

Resource & Capacity Booking

Resource-based booking extends The Booking Kit from a 1:1 provider-slot model to support capacity-aware booking of physical or virtual units. Use this feature for restaurants, yoga studios, fitness facilities, coworking spaces, hotels, and any venue where multiple identical or categorized resources share availability windows.

Use resource mode when:

  • You’re booking things, not people (tables, rooms, courts, mats, desks).
  • Multiple units of the same type exist and are interchangeable (5 yoga mats, 8 meeting rooms).
  • Capacity matters (a table seats 4, a room holds 20).
  • You want to auto-assign the best resource (smallest table that fits the party).

Use provider mode when:

  • You’re booking time with a specific person (barber, consultant, therapist).
  • The service provider’s schedule is the constraint.
  • Capacity is implicit (1 consultant per slot).

Use both when:

  • You need both a person and a thing (server + table, therapist + room).
  • Both EXCLUDE constraints operate independently and work together.

One resource = one bookable unit. The capacity field represents the maximum party size that unit can accommodate, not the number of concurrent bookings. A restaurant table capacity of 4 means: one booking per table, but that booking can seat up to 4 guests.

Resource Table (id=5, name="Table 5", capacity=4):
Booking 1: 2 guests, 6:00pm–7:00pm ✓ booked
Booking 2: 3 guests, 7:30pm–8:30pm ✓ available
(same resource cannot have overlapping bookings)

Define one resource, compute available slots, make a booking:

import {
getResourceAvailableSlots,
assignResource,
isResourceSlotAvailable,
} from "@thebookingkit/core";
import type { ResourceInput } from "@thebookingkit/core";
// 1. Define a resource
const tableResource: ResourceInput = {
id: "table-1",
name: "Table 5",
type: "2-top",
capacity: 2,
isActive: true,
rules: [
{
rrule: "RRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH,FR,SA",
startTime: "17:30",
endTime: "22:00",
timezone: "America/New_York",
},
],
overrides: [],
bookings: [],
};
// 2. Get available slots
const slots = getResourceAvailableSlots(
[tableResource],
{
start: new Date("2026-03-15T00:00:00.000Z"),
end: new Date("2026-03-15T23:59:59.999Z"),
},
"America/New_York",
{ duration: 60 }
);
console.log(slots[0]);
// {
// startTime: "2026-03-15T21:30:00.000Z",
// endTime: "2026-03-15T22:30:00.000Z",
// localStart: "2026-03-15T17:30",
// localEnd: "2026-03-15T18:30",
// availableResources: [
// {
// resourceId: "table-1",
// resourceName: "Table 5",
// resourceType: "2-top",
// remainingCapacity: 2,
// }
// ]
// }
// 3. Assign a resource for a party of 2
const assignment = assignResource(
[tableResource],
new Date("2026-03-15T21:30:00.000Z"),
new Date("2026-03-15T22:30:00.000Z"),
{ requestedCapacity: 2, strategy: "best_fit" }
);
console.log(assignment);
// {
// resourceId: "table-1",
// resourceName: "Table 5",
// reason: "best_fit"
// }

A resource is a single bookable unit with its own availability schedule and capacity.

interface ResourceInput {
// Unique identifier
id: string;
// Display name
name: string;
// Free-form category: "table", "room", "court", "desk", "mat"
// No enum — you define types as needed
type: string;
// Maximum party size this single resource can hold
capacity: number;
// Whether to include in slot computation
isActive: boolean;
// RRULE-based availability rules (shared pattern with providers)
rules: AvailabilityRuleInput[];
// Date-specific overrides (blocked dates or alternate hours)
overrides: AvailabilityOverrideInput[];
// Existing bookings for conflict checking
bookings: BookingInput[];
}
const tables: ResourceInput[] = [
{
id: "tbl-2top-1",
name: "Table 1",
type: "2-top",
capacity: 2,
isActive: true,
rules: [
{
rrule: "RRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH,FR,SA",
startTime: "11:30",
endTime: "14:00",
timezone: "America/Chicago",
},
{
rrule: "RRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH,FR,SA",
startTime: "17:30",
endTime: "22:00",
timezone: "America/Chicago",
},
],
overrides: [],
bookings: [],
},
{
id: "tbl-4top-1",
name: "Table 5",
type: "4-top",
capacity: 4,
isActive: true,
rules: [
{
rrule: "RRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH,FR,SA",
startTime: "11:30",
endTime: "14:00",
timezone: "America/Chicago",
},
{
rrule: "RRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH,FR,SA",
startTime: "17:30",
endTime: "22:00",
timezone: "America/Chicago",
},
],
overrides: [],
bookings: [],
},
{
id: "tbl-8top-1",
name: "Table 12",
type: "8-top",
capacity: 8,
isActive: true,
rules: [
{
rrule: "RRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH,FR,SA",
startTime: "11:30",
endTime: "14:00",
timezone: "America/Chicago",
},
{
rrule: "RRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH,FR,SA",
startTime: "17:30",
endTime: "22:00",
timezone: "America/Chicago",
},
],
overrides: [],
bookings: [],
},
];

Each resource has its own rules array (RRULE-based) and overrides (date-specific exceptions).

// Office meeting room with weekday hours + holiday closure
const conferenceRoom: ResourceInput = {
id: "room-a",
name: "Conference Room A",
type: "room",
capacity: 10,
isActive: true,
rules: [
{
rrule: "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
startTime: "08:00",
endTime: "18:00",
timezone: "America/New_York",
},
],
overrides: [
{
date: new Date("2026-12-25"),
isUnavailable: true, // Blocked entire day (Christmas)
},
{
date: new Date("2026-07-04"),
startTime: null,
endTime: null,
isUnavailable: true, // Blocked entire day (Independence Day)
},
{
date: new Date("2026-03-20"),
startTime: "09:00",
endTime: "17:00",
isUnavailable: false, // Alternate hours (company offsite)
},
],
bookings: [],
};

getResourceAvailableSlots() runs the three-step pipeline (RRULE expansion → override masking → capacity filtering) independently for each resource, then merges results by time slot.

import { getResourceAvailableSlots } from "@thebookingkit/core";
const slots = getResourceAvailableSlots(
resources, // ResourceInput[]
{
start: new Date("2026-03-15T00:00:00.000Z"),
end: new Date("2026-03-22T23:59:59.999Z"),
},
"America/New_York", // customer timezone for localStart/localEnd
{
duration: 60, // 60-minute booking slots
slotInterval: 30, // generate a slot every 30 minutes
}
);
// Result: ResourceSlot[]
// Each slot includes list of available resources + remaining capacity
  1. Expand rules: For each resource, convert RRULE into time windows for the date range.
  2. Apply overrides: Mask windows with date-specific blocked or alternate hours.
  3. Capacity filter: For each candidate slot, count how much capacity is consumed by overlapping bookings.

A slot is included in the result only if at least one resource has remaining capacity ≥ 1.

const smallSeatingSlots = getResourceAvailableSlots(
tables,
dateRange,
"America/Chicago",
{
duration: 90,
resourceType: "2-top", // Only 2-top tables
minCapacity: 2, // Only resources that fit parties of 2+
}
);
// Use-case: Customer selects "party of 4"
// → Filter to tables with capacity >= 4
// → Show only times where a 4-top is available
const partyOf4Slots = getResourceAvailableSlots(
tables,
dateRange,
"America/Chicago",
{
duration: 90,
minCapacity: 4, // Excludes 2-tops
}
);

Apply buffer before/after each resource booking to enforce table turnaround time:

const slots = getResourceAvailableSlots(
tables,
dateRange,
"America/Chicago",
{
duration: 90,
bufferBefore: 15, // 15 min to clean table before next seating
bufferAfter: 10, // 10 min to clear table after booking ends
}
);
// If Table 5 has a booking 6:00–7:00pm:
// → Blocked window is 5:45pm–7:10pm (includes buffers)
// → Next available slot starts at 7:10pm

assignResource() automatically selects the best resource from a pool given a time window.

StrategyUse CaseExample
best_fit (default)Minimize wasted capacityParty of 3 → assign 4-top (not 8-top)
first_availableSimplicity/FIFOBook first free resource in array order
round_robinLoad balance identical resourcesTennis courts: distribute bookings evenly
largest_firstMaximize comfort/VIPPremium customers → largest suite

Selects the smallest resource whose capacity ≥ requested party size. Minimizes wasted seats.

import { assignResource } from "@thebookingkit/core";
const assignment = assignResource(
tables,
new Date("2026-03-15T18:00:00.000Z"),
new Date("2026-03-15T19:30:00.000Z"),
{
strategy: "best_fit",
requestedCapacity: 3,
}
);
// Pool: 2-top (free), 4-top (free), 8-top (free)
// Party of 3 → 4-top selected (smallest that fits)
console.log(assignment);
// { resourceId: "tbl-4top-1", resourceName: "Table 5", reason: "best_fit" }

Takes the first free resource in array order (deterministic, no reordering).

const assignment = assignResource(
tables,
startTime,
endTime,
{ strategy: "first_available", requestedCapacity: 4 }
);
// Returns tables[0] if free, else tables[1], etc.

Distributes bookings evenly by lowest past booking count. Useful when all resources are equivalent.

const assignment = assignResource(
courts,
startTime,
endTime,
{
strategy: "round_robin",
requestedCapacity: 2,
pastCounts: [
{ resourceId: "court-1", bookingCount: 12 },
{ resourceId: "court-2", bookingCount: 8 },
{ resourceId: "court-3", bookingCount: 10 },
],
}
);
// Court 2 has lowest count (8) → selected
console.log(assignment.resourceId); // "court-2"

Pass pastCounts from your database query:

const pastCounts = await db
.select({
resourceId: bookings.resourceId,
bookingCount: sql`count(*)`.as("bookingCount"),
})
.from(bookings)
.where(eq(bookings.status, "confirmed"))
.groupBy(bookings.resourceId);
const assignment = assignResource(resources, startTime, endTime, {
strategy: "round_robin",
requestedCapacity: 2,
pastCounts,
});

Selects the largest available resource by capacity. Useful for VIP or premium experiences.

const assignment = assignResource(
suites,
startTime,
endTime,
{ strategy: "largest_first", requestedCapacity: 2 }
);
// Returns the largest suite that fits

When no resource can be assigned, ResourceUnavailableError is thrown with a specific reason:

import { assignResource, ResourceUnavailableError } from "@thebookingkit/core";
try {
const assignment = assignResource(tables, startTime, endTime, {
requestedCapacity: 6,
});
// Proceed with booking...
} catch (err) {
if (err instanceof ResourceUnavailableError) {
if (err.reason === "no_matching_type") {
// User requested a resource type that doesn't exist
console.error("No tables of that type available");
} else if (err.reason === "no_capacity") {
// Pool doesn't have any resource large enough
console.error("No table can seat this party size");
} else if (err.reason === "all_booked") {
// Suitable resources exist but all booked at this time
console.error("All tables booked at this time. Try another time.");
}
}
}

Validate whether one specific resource is available at a time:

import { isResourceSlotAvailable } from "@thebookingkit/core";
const result = isResourceSlotAvailable(
tables,
"tbl-4top-1", // Check this specific resource
new Date("2026-03-15T18:00:00.000Z"),
new Date("2026-03-15T19:30:00.000Z"),
15, // bufferBefore
10 // bufferAfter
);
if (result.available) {
console.log(`Available. Remaining capacity: ${result.remainingCapacity}`);
} else {
console.log(`Unavailable. Reason: ${result.reason}`);
// Reasons: "outside_availability", "resource_booked", "blocked_date",
// "buffer_conflict", "resource_inactive"
}

Omit resourceId to check if any active resource in the pool is available:

const result = isResourceSlotAvailable(
tables,
undefined, // Check pool-level (any resource)
startTime,
endTime,
15,
10
);
if (result.available) {
console.log(`Pool available. Best capacity: ${result.remainingCapacity}`);
// Proceed with assignResource()
} else {
console.log("No resources available at this time.");
}

getResourcePoolSummary() provides real-time utilization metrics for admin dashboards.

import { getResourcePoolSummary } from "@thebookingkit/core";
const summary = getResourcePoolSummary(
tables,
{
start: new Date("2026-03-15T00:00:00.000Z"),
end: new Date("2026-03-15T23:59:59.999Z"),
},
"America/Chicago",
{
duration: 60,
slotInterval: 15, // 15-minute summary intervals
}
);
console.log(summary[10]);
// {
// startTime: "2026-03-15T17:00:00.000Z",
// endTime: "2026-03-15T18:00:00.000Z",
// localStart: "2026-03-15T12:00",
// localEnd: "2026-03-15T13:00",
// totalResources: 15, // 8 2-tops + 5 4-tops + 2 8-tops
// availableResources: 4, // 11 currently booked
// utilizationPercent: 73, // (15 - 4) / 15 * 100
// byType: {
// "2-top": { total: 8, available: 1 },
// "4-top": { total: 5, available: 2 },
// "8-top": { total: 2, available: 1 },
// }
// }

Perfect for a host station display or restaurant manager dashboard:

// Example: Restaurant host screen
const summaries = getResourcePoolSummary(tables, dateRange, tz);
const current = summaries[0]; // Current time slot
return (
<div className="p-4">
<h2>Restaurant Status</h2>
<p className="text-lg">
{current.availableResources} of {current.totalResources} tables available
</p>
<div className="flex gap-4">
{Object.entries(current.byType).map(([type, counts]) => (
<div key={type} className="text-center">
<div className="text-sm font-semibold">{type}</div>
<div className="text-xs text-gray-600">
{counts.available} / {counts.total}
</div>
</div>
))}
</div>
</div>
);

Book tables with party-size-based auto-assignment:

import {
getResourceAvailableSlots,
assignResource,
isResourceSlotAvailable,
} from "@thebookingkit/core";
// 1. Customer selects date, time, and party size
const partySize = 4;
const selectedSlot = "2026-03-15T18:00:00Z";
// 2. Load tables from database
const tables = await db.query.resources.findMany({
where: (table) => eq(table.type, "restaurant-table"),
with: { bookings: true, rules: true, overrides: true },
});
// 3. Get available slots filtered by party size
const slots = getResourceAvailableSlots(
tables,
{
start: new Date("2026-03-15T00:00:00.000Z"),
end: new Date("2026-03-15T23:59:59.999Z"),
},
"America/Chicago",
{
duration: 90,
minCapacity: partySize,
bufferBefore: 15,
bufferAfter: 10,
}
);
// 4. Check that user's selected slot is still available
const availability = isResourceSlotAvailable(
tables,
undefined,
new Date(selectedSlot),
new Date(new Date(selectedSlot).getTime() + 90 * 60_000),
15,
10
);
if (!availability.available) {
throw new Error("That time slot is no longer available.");
}
// 5. Auto-assign the best table for the party
const assignment = assignResource(
tables,
new Date(selectedSlot),
new Date(new Date(selectedSlot).getTime() + 90 * 60_000),
{
strategy: "best_fit",
requestedCapacity: partySize,
bufferBefore: 15,
bufferAfter: 10,
}
);
// 6. Create the booking
const booking = await db.insert(bookings).values({
id: uuidv4(),
resourceId: assignment.resourceId,
customerId: currentUser.id,
startsAt: new Date(selectedSlot),
endsAt: new Date(new Date(selectedSlot).getTime() + 90 * 60_000),
status: "pending",
guestCount: partySize,
createdAt: new Date(),
updatedAt: new Date(),
});
console.log(`Reserved ${assignment.resourceName} for ${partySize} at 6:00pm`);

Book a spot in a class with a max capacity:

import {
getResourceAvailableSlots,
isResourceSlotAvailable,
} from "@thebookingkit/core";
// A yoga class is a "resource" with capacity = max students
const yogaClass: ResourceInput = {
id: "class-morning-flow",
name: "Morning Flow (Mon–Fri 7am)",
type: "yoga-class",
capacity: 20,
isActive: true,
rules: [
{
rrule: "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
startTime: "07:00",
endTime: "08:00",
timezone: "America/Los_Angeles",
},
],
overrides: [],
bookings: currentBookings, // Existing drop-in bookings
};
// Check if spots are available
const result = isResourceSlotAvailable(
[yogaClass],
"class-morning-flow",
classStartTime,
classEndTime
);
if (result.available) {
console.log(
`Class available: ${result.remainingCapacity} of 20 spots remaining`
);
// Create drop-in booking
const booking = await db.insert(bookings).values({
resourceId: "class-morning-flow",
studentId: currentStudent.id,
startsAt: classStartTime,
endsAt: classEndTime,
status: "confirmed",
guestCount: 1, // Drop-in = 1 spot
});
} else {
console.error(`Class full! Reason: ${result.reason}`);
}

Coworking space: hot desks and meeting rooms

Section titled “Coworking space: hot desks and meeting rooms”

Mixed-type booking with different availability:

const coworkingResources: ResourceInput[] = [
// Hot desks: shared seating, available during business hours
{
id: "hotdesk-1",
name: "Desk 101",
type: "hot-desk",
capacity: 1,
isActive: true,
rules: [
{
rrule: "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
startTime: "08:00",
endTime: "18:00",
timezone: "America/New_York",
},
],
overrides: [],
bookings: [],
},
// Meeting rooms: bookable by the hour
{
id: "meetingroom-a",
name: "Meeting Room A",
type: "meeting-room",
capacity: 8,
isActive: true,
rules: [
{
rrule: "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
startTime: "08:00",
endTime: "18:00",
timezone: "America/New_York",
},
],
overrides: [],
bookings: [],
},
];
// Member looking for a meeting room for 5 people
const meetingSlots = getResourceAvailableSlots(
coworkingResources,
dateRange,
"America/New_York",
{
duration: 60,
resourceType: "meeting-room",
minCapacity: 5,
bufferBefore: 15,
}
);
// Manager checking hot desk availability
const deskSlots = getResourceAvailableSlots(
coworkingResources,
dateRange,
"America/New_York",
{
duration: 480, // 8-hour day passes
resourceType: "hot-desk",
}
);

Book both a person and a thing (e.g., server + table, therapist + private room):

// Existing team member data
const teamMember: TeamMemberInput = {
userId: "server-maria",
role: "member",
priority: 1,
weight: 100,
rules: serverAvailability,
overrides: [],
bookings: serverBookings,
};
// Resource (table)
const table: ResourceInput = {
id: "table-5",
name: "Table 5",
type: "4-top",
capacity: 4,
isActive: true,
rules: restaurantHours,
overrides: [],
bookings: tableBookings,
};
// 1. Check both availability independently
const serverAvailable = isSlotAvailable(
[teamMember],
startTime,
endTime
);
const tableAvailable = isResourceSlotAvailable(
[table],
"table-5",
startTime,
endTime
);
if (!serverAvailable.available || !tableAvailable.available) {
throw new Error("Time slot not available (server or table booked)");
}
// 2. Assign both
const serverAssignment = assignHost([teamMember], [], "fixed", "server-maria");
const tableAssignment = assignResource([table], startTime, endTime, {
strategy: "best_fit",
requestedCapacity: partySize,
});
// 3. Create booking with both
const booking = await db.insert(bookings).values({
providerId: serverAssignment.hostId, // Server Maria
resourceId: tableAssignment.resourceId, // Table 5
customerId: guest.id,
startsAt: startTime,
endsAt: endTime,
status: "confirmed",
guestCount: partySize,
});
console.log(
`Booked ${serverAssignment.hostName} at ${tableAssignment.resourceName}`
);
// "Booked Maria at Table 5"

Both the provider EXCLUDE constraint and the resource EXCLUDE constraint operate independently on the bookings table. A booking can only proceed if both provider_id slot and resource_id slot pass.

Resources use three new tables in Postgres:

CREATE TABLE resources (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
slug VARCHAR UNIQUE,
type VARCHAR NOT NULL, -- "table", "room", "court", "desk"
capacity INTEGER NOT NULL, -- max party size for this unit
location TEXT, -- e.g., "patio", "floor-2"
is_active BOOLEAN DEFAULT true,
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_resources_organization_id ON resources(organization_id);
CREATE INDEX idx_resources_type ON resources(type);
CREATE INDEX idx_resources_slug ON resources(slug);
CREATE TABLE resource_availability_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
resource_id UUID NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
rrule TEXT NOT NULL,
start_time VARCHAR(5),
end_time VARCHAR(5),
timezone VARCHAR(100),
valid_from TIMESTAMPTZ,
valid_until TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_resource_availability_rules_resource_id
ON resource_availability_rules(resource_id);
CREATE TABLE resource_availability_overrides (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
resource_id UUID NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
date DATE NOT NULL,
start_time VARCHAR(5),
end_time VARCHAR(5),
is_unavailable BOOLEAN NOT NULL,
reason TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_resource_availability_overrides_resource_id
ON resource_availability_overrides(resource_id);
-- Add to existing bookings table:
ALTER TABLE bookings ADD COLUMN resource_id UUID
REFERENCES resources(id) ON DELETE SET NULL;
-- Exclusion constraint: no overlapping bookings for same resource
ALTER TABLE bookings ADD CONSTRAINT bookings_resource_no_overlap
EXCLUDE USING gist (
resource_id WITH =,
tstzrange(starts_at, ends_at) WITH &&
) WHERE (
status NOT IN ('cancelled', 'rejected', 'rescheduled')
AND resource_id IS NOT NULL
);
CREATE INDEX idx_bookings_resource_id ON bookings(resource_id);

See the Database Schema guide for setup instructions.