Team Scheduling
When your business has multiple providers — barbers, doctors, instructors, massage therapists — team scheduling distributes bookings across them automatically based on your chosen strategy. Instead of managing each provider independently, you define a team and let the system handle fair distribution.
When to use team scheduling
Section titled “When to use team scheduling”Team scheduling answers these questions:
- Single provider, one availability window? Use
getAvailableSlots— no team needed. Team scheduling adds overhead you don’t need. - Multiple providers, book any available provider? Use team scheduling with round-robin (the most common scenario: salon with multiple barbers, clinic with multiple doctors).
- All team members must be free at the same time? Use collective (panel interviews, group consultations, team meetings).
- Organization-created template with locked fields? Use managed (franchise/chain operations with consistent offerings but member-level customization).
- Customer wants to book a specific provider? Use fixed (patient’s regular doctor, client’s favorite barber) — just filter to that member.
How team slots work
Section titled “How team slots work”getTeamSlots() computes each team member’s individual availability, then merges the results based on your strategy:
| Strategy | Merge Logic | Slot Shown? | Use Case |
|---|---|---|---|
| Round-Robin | Union | Any member free = YES | Barber salon, dental practice |
| Collective | Intersection | All members free = YES | Group interview, team meeting |
| Managed | Union | Any member free = YES | Franchise with templates |
| Fixed | Direct | Only chosen member | Customer’s regular provider |
Every returned slot includes availableMembers — an array of user IDs available at that time. This powers assignment algorithms.
The return type: TeamSlot
Section titled “The return type: TeamSlot”interface TeamSlot extends Slot { // Which members are free at this time availableMembers: string[];}
// Example result:// {// startTime: "2026-03-09T14:00:00.000Z",// endTime: "2026-03-09T14:30:00.000Z",// localStart: "2026-03-09T09:00:00",// localEnd: "2026-03-09T09:30:00",// availableMembers: ["alice", "bob"] // Both are free// }Round-Robin Strategy
Section titled “Round-Robin Strategy”Most common. Distributes bookings evenly across team members using priority and weight ratios.
How it works
Section titled “How it works”- Computes each member’s individual slots
- Returns the union — any time slot where at least one member is free
- Each slot tracks which members are available
- When assigning, picks the best-balanced member based on:
isFixedflag (fixed members get priority)prioritynumber (lower = higher priority)weightratio (relative distribution — 100:50 = 2:1 booking ratio)- Past booking count (load balancing across members)
Key fields explained
Section titled “Key fields explained”interface TeamMemberInput { userId: string; role: "admin" | "member";
// Lower = checked first when multiple members are equally available priority: number; // e.g., alice=1, bob=2
// Weight ratio determines target booking distribution weight: number; // e.g., alice=100, bob=50 → 2:1 ratio
// Fixed members always get assigned if available (e.g., manager on shift) isFixed?: boolean;
// Everything else: their personal availability rules: AvailabilityRuleInput[]; overrides: AvailabilityOverrideInput[]; bookings: BookingInput[];}Weight vs Priority: Priority is a tiebreaker when members have the same availability. Weight is the long-term target ratio. If Alice has weight: 100 and Bob has weight: 50, Alice should get ~2x as many bookings as Bob over time.
Example: Multi-Barber Salon
Section titled “Example: Multi-Barber Salon”Three barbers with different schedules and load balancing:
import { getTeamSlots, assignHost } from "@thebookingkit/core";import type { TeamMemberInput, MemberBookingCount } from "@thebookingkit/core";
// Barber availability and workloadconst barbers: TeamMemberInput[] = [ { userId: "alice", role: "member", priority: 1, // Alice is primary barber weight: 100, // Target 50% of bookings rules: [ { rrule: "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA", startTime: "09:00", endTime: "18:00", timezone: "America/New_York", }, ], overrides: [], bookings: aliceBookings, // Load from database }, { userId: "bob", role: "member", priority: 2, // Bob is secondary weight: 100, // Target 50% of bookings rules: [ { rrule: "RRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH,FR,SA,SU", startTime: "11:00", endTime: "20:00", timezone: "America/New_York", }, ], overrides: [], bookings: bobBookings, }, { userId: "charlie", role: "member", priority: 3, // Charlie is part-time weight: 50, // Target 25% of bookings (half Alice's ratio) rules: [ { rrule: "RRULE:FREQ=WEEKLY;BYDAY=SA,SU", startTime: "10:00", endTime: "17:00", timezone: "America/New_York", }, ], overrides: [], bookings: charlieBookings, },];
// Step 1: Get team slots (union of all barber availability)const slots = getTeamSlots( barbers, "round_robin", { start: new Date("2026-03-09T00:00:00.000Z"), end: new Date("2026-03-15T23:59:59.999Z"), }, "America/New_York", { duration: 30 } // 30-minute haircuts);
console.log(slots[0]);// {// startTime: "2026-03-09T14:00:00.000Z",// endTime: "2026-03-09T14:30:00.000Z",// localStart: "2026-03-09T09:00:00",// localEnd: "2026-03-09T09:30:00",// availableMembers: ["alice", "bob"] // Only these barbers are free// }
// Step 2: Customer picks a time slotconst selectedSlot = slots[5];
// Step 3: Assign the best barber for that slot// Track confirmed bookings per barber (from database)const bookingCounts: MemberBookingCount[] = [ { userId: "alice", confirmedCount: 24 }, { userId: "bob", confirmedCount: 18 }, { userId: "charlie", confirmedCount: 8 },];
// assignHost picks based on:// - Who's available at this slot (availableMembers)// - Priority (if different)// - Weight ratio (who should get more bookings)// - Past booking count (load balance)const assignment = assignHost( barbers, selectedSlot.availableMembers, // ["alice", "bob"] from the slot bookingCounts);
console.log(assignment);// {// hostId: "bob",// reason: "weight_balanced"// }// Bob gets this booking because:// - Both alice and bob are available// - Same priority tiebreaker// - Bob's booking count (18) is further below his target than Alice's (24)The assignment algorithm
Section titled “The assignment algorithm”assignHost() selects in this order:
- Fixed members first: If any member has
isFixed: trueand is available, they get selected. Use this for a manager who must always work. - Highest priority: Among available members, pick those with the lowest
prioritynumber. - Weight-balanced: Among same priority, pick the member whose actual booking ratio is furthest below their target weight ratio.
The third step uses this formula:
For each member: targetRatio = member.weight / (sum of all weights) expectedBookings = totalBookings * targetRatio deficit = expectedBookings - actualBookings → Pick member with highest deficit (most "underbooked")Example: Alice (weight 100) and Bob (weight 100) at same priority:
- Total bookings: 42
- Target ratio each: 50% = 21 bookings
- Alice actual: 24 bookings → deficit = 21 - 24 = -3 (slightly overbooked)
- Bob actual: 18 bookings → deficit = 21 - 18 = +3 (underbooked)
- Bob wins because +3 > -3
Collective Strategy
Section titled “Collective Strategy”For group availability. All team members must be free at the same time.
How it works
Section titled “How it works”Returns only slots where every team member is available. Uses intersection logic:
- Slot is included only if all members have no conflicts
- If any member is booked, that slot vanishes
availableMembersalways contains all members (or is empty)
Use cases
Section titled “Use cases”- Panel interviews: HR, hiring manager, team lead must all interview together
- Group consultations: Lead therapist + assistants all present
- Team meetings: All stakeholders available
- Medical rounds: Specialist + residents + nurses
Example: Panel Interview Room
Section titled “Example: Panel Interview Room”import { getTeamSlots } from "@thebookingkit/core";import type { TeamMemberInput } from "@thebookingkit/core";
const interviewPanel: TeamMemberInput[] = [ { userId: "hr-manager", role: "admin", priority: 1, weight: 100, rules: [ { rrule: "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", startTime: "09:00", endTime: "17:00", timezone: "America/New_York", }, ], overrides: [], bookings: hrBookings, }, { userId: "hiring-manager", role: "member", priority: 1, weight: 100, rules: [ { rrule: "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", startTime: "09:00", endTime: "17:00", timezone: "America/New_York", }, ], overrides: [], bookings: hmBookings, }, { userId: "team-lead", role: "member", priority: 1, weight: 100, rules: [ { rrule: "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,FR", // Friday off startTime: "09:00", endTime: "17:00", timezone: "America/New_York", }, ], overrides: [], bookings: tlBookings, },];
// Collective: only times when ALL three are freeconst slots = getTeamSlots( interviewPanel, "collective", { start: new Date("2026-03-09T00:00:00.000Z"), end: new Date("2026-03-15T23:59:59.999Z"), }, "America/New_York", { duration: 60 } // 1-hour interviews);
// Result: Much fewer slots than round-robin// Friday slots EXCLUDED because team-lead is unavailable// Any time someone is booked 2-3pm → that slot is excluded entirelyconsole.log(slots.length); // Maybe 20 slots instead of 100Notice: Collective produces far fewer slots because it requires perfect alignment. Use this only when everyone truly must be present.
Managed Strategy
Section titled “Managed Strategy”For franchises and chains. Organization creates a template with locked fields; members inherit and customize unlocked fields.
How it works
Section titled “How it works”- Admin creates an
ManagedEventTypeTemplatewith field locks - Members get auto-generated event types based on the template
- Locked fields can’t be changed by members (title, duration, buffer)
- Unlocked fields are customizable (description, price, custom questions)
- When admin updates the template, locked fields propagate to all members automatically
The template model
Section titled “The template model”interface ManagedEventTypeTemplate { title: string; durationMinutes: number; bufferBefore: number; bufferAfter: number; customQuestions: unknown[]; priceCents?: number; lockedFields: ManagedFieldLock[]; // Define which fields are locked}
interface ManagedFieldLock { field: string; locked: boolean; // true = member can't change}Example: Massage Franchise
Section titled “Example: Massage Franchise”Corporate defines the core “Swedish Massage” service. All locations offer the same duration, buffer, and questions. But each location can set their own price and therapist bio.
import { resolveManagedEventType, isFieldLocked, propagateTemplateChanges,} from "@thebookingkit/core";import type { ManagedEventTypeTemplate, MemberEventTypeOverride,} from "@thebookingkit/core";
// Corporate defines the templateconst corporateTemplate: ManagedEventTypeTemplate = { title: "Swedish Massage", durationMinutes: 60, bufferBefore: 15, // 15 min prep bufferAfter: 15, // 15 min cleanup customQuestions: [ { id: "health", text: "Any injuries or health concerns?", required: true, }, ], priceCents: 9000, // Corporate default $90 lockedFields: [ { field: "title", locked: true }, // Can't rename service { field: "durationMinutes", locked: true }, // All therapists: 60 min { field: "bufferBefore", locked: true }, // Corporate standard { field: "customQuestions", locked: true }, // Same questions everywhere { field: "priceCents", locked: false }, // Locations set own price { field: "description", locked: false }, // Therapist can describe their style ],};
// Check what therapists can customizeisFieldLocked(corporateTemplate, "title"); // true — can't changeisFieldLocked(corporateTemplate, "priceCents"); // false — can customizeisFieldLocked(corporateTemplate, "description"); // false — can customize
// Therapist Alice's custom overridesconst aliceOverrides: MemberEventTypeOverride = { userId: "alice", overrides: { priceCents: 10000, // Alice charges $100 (more experienced) description: "Therapeutic massage with deep tissue focus", },};
// Resolve Alice's event type: locked fields from template, overrides for unlockedconst aliceEventType = resolveManagedEventType( corporateTemplate, aliceOverrides);
console.log(aliceEventType);// {// userId: "alice",// config: {// title: "Swedish Massage", // From template (locked)// durationMinutes: 60, // From template (locked)// bufferBefore: 15, // From template (locked)// customQuestions: [{ ... }], // From template (locked)// priceCents: 10000, // From alice's override (unlocked)// description: "Therapeutic massage...", // From alice's override (unlocked)// }// }
// --- Later: Corporate updates the template ---
// New duration for all therapists (better scheduling)const updatedTemplate: ManagedEventTypeTemplate = { ...corporateTemplate, durationMinutes: 90, // Expanded from 60 to 90 minutes};
// Propagate locked field changes to all therapistsconst memberOverrides: MemberEventTypeOverride[] = [ aliceOverrides, bobOverrides, charlieOverrides,];
const propagated = propagateTemplateChanges(updatedTemplate, memberOverrides);
propagated.forEach((result) => { console.log(`${result.userId}: duration now ${result.config.durationMinutes} min`);});// alice: duration now 90 min// bob: duration now 90 min// charlie: duration now 90 min// (Their custom prices unchanged)Key concepts
Section titled “Key concepts”- Locked fields: Enforced across entire organization. Use for service consistency.
- Unlocked fields: Members customize. Use for differentiation (price, bio, questions).
- Propagation:
propagateTemplateChanges()updates all members’ locked field values when the template changes. Unlocked field customizations are preserved.
Fixed Strategy
Section titled “Fixed Strategy”For customer preference. Book a specific team member directly. No automatic assignment.
How it works
Section titled “How it works”getTeamSlotsstill returns union slots (any member free)assignHostis skipped — you manually specifyuserId- Filter the team array to just one member before calling
getTeamSlots
Example: Patient Books Their Regular Doctor
Section titled “Example: Patient Books Their Regular Doctor”import { getTeamSlots } from "@thebookingkit/core";import type { TeamMemberInput } from "@thebookingkit/core";
// Patient wants Dr. Sarah (their regular doctor)const doctorId = "dr-sarah";
// Get all doctorsconst allDoctors: TeamMemberInput[] = [/* ... */];
// Filter to just Dr. Sarahconst regularDoctor = allDoctors.filter((d) => d.userId === doctorId);
// Compute her available slotsconst slots = getTeamSlots( regularDoctor, "fixed", // or any strategy — doesn't matter for one member { start: new Date("2026-03-09T00:00:00.000Z"), end: new Date("2026-03-15T23:59:59.999Z"), }, "America/New_York", { duration: 30 });
// Result: Only times when Dr. Sarah is freeconsole.log(slots);// [// { ..., availableMembers: ["dr-sarah"] },// { ..., availableMembers: ["dr-sarah"] },// // ... more slots, all with just Dr. Sarah// ]
// Create booking with fixed providerconst booking = await db.insert(bookings).values({ id: uuidv4(), providerId: doctorId, // Explicit assignment customerId: patientId, startsAt: slots[3].startTime, endsAt: slots[3].endTime, status: "confirmed",});Complete example: Multi-Barber Salon
Section titled “Complete example: Multi-Barber Salon”A realistic flow: Setup → Display slots → Customer picks time → Auto-assign barber → Book.
import { getTeamSlots, assignHost } from "@thebookingkit/core";import type { TeamMemberInput, MemberBookingCount } from "@thebookingkit/core";
// --- Step 1: Define team ---const barbers: TeamMemberInput[] = [ { userId: "alice", role: "member", priority: 1, weight: 100, rules: [ { rrule: "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA", startTime: "09:00", endTime: "18:00", timezone: "America/New_York", }, ], overrides: [ { date: new Date("2026-03-17"), isUnavailable: true, // Alice on vacation }, ], bookings: aliceBookings, }, { userId: "bob", role: "member", priority: 2, weight: 100, rules: [ { rrule: "RRULE:FREQ=WEEKLY;BYDAY=TU,WE,TH,FR,SA,SU", startTime: "11:00", endTime: "20:00", timezone: "America/New_York", }, ], overrides: [], bookings: bobBookings, }, { userId: "charlie", role: "member", priority: 3, weight: 50, // Part-time: 25% of bookings rules: [ { rrule: "RRULE:FREQ=WEEKLY;BYDAY=SA,SU", startTime: "10:00", endTime: "17:00", timezone: "America/New_York", }, ], overrides: [], bookings: charlieBookings, },];
// --- Step 2: Load booking counts from database ---const bookingCounts: MemberBookingCount[] = [ { userId: "alice", confirmedCount: 32 }, { userId: "bob", confirmedCount: 28 }, { userId: "charlie", confirmedCount: 10 },];
// --- Step 3: Get available slots ---const startDate = new Date("2026-03-09T00:00:00.000Z");const endDate = new Date("2026-03-15T23:59:59.999Z");
const slots = getTeamSlots( barbers, "round_robin", { start: startDate, end: endDate }, "America/New_York", { duration: 30, // 30-minute haircuts slotInterval: 15, // Show a slot every 15 minutes });
console.log(`${slots.length} available slots this week`);
// --- Step 4: Display to customer (e.g., in Next.js route) ---// GET /api/availabilityexport async function GET(request: Request) { return Response.json(slots);}
// --- Step 5: Customer selects a time ---// POST /api/bookingsexport async function POST(request: Request) { const { slotIndex, customerEmail } = await request.json();
const selectedSlot = slots[slotIndex];
// --- Step 6: Assign barber --- const assignment = assignHost( barbers, selectedSlot.availableMembers, // ["alice", "bob"] or ["bob"] etc. bookingCounts );
console.log(`Assigning ${assignment.hostId} (reason: ${assignment.reason})`);
// --- Step 7: Create booking --- const booking = await db.insert(bookings).values({ id: uuidv4(), organizationId: org.id, providerId: assignment.hostId, // barber assigned by system customerId: currentUser.id, startsAt: new Date(selectedSlot.startTime), endsAt: new Date(selectedSlot.endTime), status: "confirmed", createdAt: new Date(), updatedAt: new Date(), });
// Update confirmation email await sendConfirmationEmail(customerEmail, { barberName: assignment.hostId, date: selectedSlot.localStart, time: selectedSlot.localStart.split("T")[1], });
return Response.json({ bookingId: booking.id, barber: assignment.hostId });}Tips and patterns
Section titled “Tips and patterns”Adding a new team member
Section titled “Adding a new team member”Existing bookings are unaffected. The system rebalances future assignments based on the new member’s weight:
// Add Charlie to the team mid-yearconst newTeam = [ ...existingBarbers, { userId: "charlie", role: "member", priority: 3, weight: 50, // Start with low weight, increase over time rules: charlieRules, overrides: [], bookings: [], // No past bookings },];
// Refresh the assignment logic (Charlie's low weight means other barbers// get bookings until the load balances out)Handling time off
Section titled “Handling time off”Members going on vacation don’t require adding/removing them. Just add an availability override:
const aliceWithVacation: TeamMemberInput = { ...alice, overrides: [ ...alice.overrides, { date: new Date("2026-06-15"), isUnavailable: true, // Alice unavailable June 15 }, { date: new Date("2026-06-16"), isUnavailable: true, // June 16 }, // ... more days ],};
// Re-run getTeamSlots with updated teamconst slots = getTeamSlots(newTeam, "round_robin", dateRange, tz, options);// Slots during Alice's vacation show only Bob and CharliePreferred vs available
Section titled “Preferred vs available”If a customer has a preferred provider but accepts anyone:
// Show preferred barber first, then othersconst preferredBarber = "alice";
const slots = getTeamSlots(barbers, "round_robin", dateRange, tz, options);
// Sort: slots with preferred barber firstconst sorted = slots.sort((a, b) => { const aHasPreferred = a.availableMembers.includes(preferredBarber); const bHasPreferred = b.availableMembers.includes(preferredBarber); if (aHasPreferred && !bHasPreferred) return -1; if (!aHasPreferred && bHasPreferred) return 1; return 0;});
// Display sorted list to customerLoad balancing across different weights
Section titled “Load balancing across different weights”Example: Alice (2 chairs) vs Bob (1 chair):
const barbers: TeamMemberInput[] = [ { userId: "alice", weight: 100, ... }, // 2 chairs { userId: "bob", weight: 50, ... }, // 1 chair];
// Over time, Alice gets 2x as many bookings because her weight is 2x higher.// The assignment algorithm keeps the ratio balanced automatically.Database integration
Section titled “Database integration”Load team data from your database:
// GET all team members with their availabilityconst team = await db.query.users.findMany({ where: (user) => eq(user.organizationId, orgId), with: { availabilityRules: true, availabilityOverrides: true, bookings: true, },});
// Map to TeamMemberInputconst teamInput: TeamMemberInput[] = team.map((member) => ({ userId: member.id, role: member.role as "admin" | "member", priority: member.priority ?? 999, weight: member.weight ?? 100, isFixed: member.isFixed ?? false, rules: member.availabilityRules, overrides: member.availabilityOverrides, bookings: member.bookings,}));
// Use in getTeamSlotsconst slots = getTeamSlots(teamInput, "round_robin", ...);UI component
Section titled “UI component”The <TeamAssignmentEditor /> component lets admins configure team members and strategies:
import { TeamAssignmentEditor } from "./components/team-assignment-editor";
export function EventTypeSettings() { const [members, setMembers] = useState<TeamMemberInput[]>([]); const [strategy, setStrategy] = useState<AssignmentStrategy>("round_robin");
return ( <TeamAssignmentEditor members={members} strategy={strategy} onStrategyChange={setStrategy} onMembersChange={setMembers} onPriorityChange={(userId, newPriority) => { // Update database }} onWeightChange={(userId, newWeight) => { // Update database }} /> );}Related
Section titled “Related”- Slot Engine — How individual availability is computed
- Availability Rules — RRULE syntax and overrides
- Resource Booking — Booking physical resources (tables, rooms)
- Fixed Bookings — Book one specific provider