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.
When to use resource mode
Section titled “When to use resource mode”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.
Unit model clarification
Section titled “Unit model clarification”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)Quick start
Section titled “Quick start”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 resourceconst 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 slotsconst 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 2const 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"// }Defining resources
Section titled “Defining resources”A resource is a single bookable unit with its own availability schedule and capacity.
ResourceInput interface
Section titled “ResourceInput interface”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[];}Restaurant table example
Section titled “Restaurant table example”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: [], },];Per-resource availability rules
Section titled “Per-resource availability rules”Each resource has its own rules array (RRULE-based) and overrides (date-specific exceptions).
// Office meeting room with weekday hours + holiday closureconst 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: [],};Computing available slots
Section titled “Computing available slots”getResourceAvailableSlots() runs the three-step pipeline (RRULE expansion → override masking → capacity filtering) independently for each resource, then merges results by time slot.
Basic usage
Section titled “Basic usage”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 capacityPipeline internals
Section titled “Pipeline internals”- Expand rules: For each resource, convert RRULE into time windows for the date range.
- Apply overrides: Mask windows with date-specific blocked or alternate hours.
- 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.
Filtering by resource type and capacity
Section titled “Filtering by resource type and capacity”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 availableconst partyOf4Slots = getResourceAvailableSlots( tables, dateRange, "America/Chicago", { duration: 90, minCapacity: 4, // Excludes 2-tops });Buffer time per resource
Section titled “Buffer time per resource”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:10pmAuto-assignment strategies
Section titled “Auto-assignment strategies”assignResource() automatically selects the best resource from a pool given a time window.
Strategy comparison
Section titled “Strategy comparison”| Strategy | Use Case | Example |
|---|---|---|
best_fit (default) | Minimize wasted capacity | Party of 3 → assign 4-top (not 8-top) |
first_available | Simplicity/FIFO | Book first free resource in array order |
round_robin | Load balance identical resources | Tennis courts: distribute bookings evenly |
largest_first | Maximize comfort/VIP | Premium customers → largest suite |
Best fit (default)
Section titled “Best fit (default)”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" }First available
Section titled “First available”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.Round robin
Section titled “Round robin”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) → selectedconsole.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,});Largest first
Section titled “Largest first”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 fitsHandling unavailability errors
Section titled “Handling unavailability errors”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."); } }}Checking availability
Section titled “Checking availability”Single resource check
Section titled “Single resource check”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"}Pool-level check (any resource)
Section titled “Pool-level check (any resource)”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.");}Admin dashboard: Pool summary
Section titled “Admin dashboard: Pool summary”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 screenconst 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>);Use-case recipes
Section titled “Use-case recipes”Restaurant reservations
Section titled “Restaurant reservations”Book tables with party-size-based auto-assignment:
import { getResourceAvailableSlots, assignResource, isResourceSlotAvailable,} from "@thebookingkit/core";
// 1. Customer selects date, time, and party sizeconst partySize = 4;const selectedSlot = "2026-03-15T18:00:00Z";
// 2. Load tables from databaseconst 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 sizeconst 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 availableconst 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 partyconst 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 bookingconst 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`);Yoga studio drop-in class
Section titled “Yoga studio drop-in class”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 studentsconst 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 availableconst 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 peopleconst meetingSlots = getResourceAvailableSlots( coworkingResources, dateRange, "America/New_York", { duration: 60, resourceType: "meeting-room", minCapacity: 5, bufferBefore: 15, });
// Manager checking hot desk availabilityconst deskSlots = getResourceAvailableSlots( coworkingResources, dateRange, "America/New_York", { duration: 480, // 8-hour day passes resourceType: "hot-desk", });Combined provider + resource
Section titled “Combined provider + resource”Book both a person and a thing (e.g., server + table, therapist + private room):
// Existing team member dataconst 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 independentlyconst 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 bothconst serverAssignment = assignHost([teamMember], [], "fixed", "server-maria");const tableAssignment = assignResource([table], startTime, endTime, { strategy: "best_fit", requestedCapacity: partySize,});
// 3. Create booking with bothconst 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.
Database schema
Section titled “Database schema”Resources use three new tables in Postgres:
resources
Section titled “resources”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);resource_availability_rules
Section titled “resource_availability_rules”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);resource_availability_overrides
Section titled “resource_availability_overrides”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);bookings (updated)
Section titled “bookings (updated)”-- 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 resourceALTER 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.
Related
Section titled “Related”- Team Scheduling — Assign bookings to team members
- Seat & Group Bookings — Manage capacity within one time slot
- Availability Rules — RRULE and override patterns