Canonical source: docs/claude/scheduling.md. This page is generated by docs/scripts/sync-handbook.mjs. Edit the source file in the repo; changes appear here on the next build.
Scheduling System
The scheduling system is complex with multiple layers of availability, constraints, and membership rules.
Availability Hierarchy
- Location Availability — Base hours for location
- Clinician Availability — Per-staff schedule
- Service Implementation — When service offered at location
- Blocked Intervals — Holidays, PTO, breaks
Final available slots = intersection of (1, 2, 3) minus (4).
Key Concepts
| Concept | Description | Storage |
|---|---|---|
| Location | Physical clinic location with address, timezone, base availability | clinicsv2/{clinic}.locations[] |
| Service | Type of appointment (e.g., "Office Visit", "Telehealth") | clinicsv2/{clinic}.services[] |
| Service Implementation | How a service is offered at a specific location | location.services[] |
| Availability Block | Recurring weekly availability (day, start, end, location) | clinician.availability.blocks[] |
| AvailabilityV2 | Per-clinician recurring rules | clinicsv2/{clinic}/clinicians/{uid}/availabilityV2 |
| Blocked Intervals | Exact time ranges when unavailable | clinician.blockedIntervals[] |
| Holidays | Clinic-wide closures | clinicsv2/{clinic}/holidays/{id} |
Membership Constraints
Memberships control what clients can book:
| Constraint | Field | Description |
|---|---|---|
| Service Access | benefits[].serviceId | Which services membership includes |
| Free Bookings | benefits[].freeBookings | Number of free sessions per period |
| Booking Window (min) | joinPolicies[].minBookingTime | How far in advance required |
| Booking Window (max) | joinPolicies[].maxBookingTime | How far ahead allowed |
| Location Access | accessibleLocationIds | Which locations member can use |
| Cancellation Policy | joinPolicies[].cancellationPolicy | When cancellation is "late" |
| Credit Pool | creditPoolConfig | Capacity-based booking limits |
| Package Limits | totalSessionLimit | Total sessions for package memberships |
Slot Availability Algorithm
# Simplified logic from get_available_service_times()
1. Get location availability blocks for the date range
2. Get clinician availability blocks (if service requires coach)
3. Intersect location + clinician availability
4. Subtract blocked intervals (holidays, PTO)
5. Subtract existing appointments (conflict check)
6. Apply service slot duration/cadence
7. Filter by membership constraints (booking window, location access)
8. For group services: check capacity vs current participants
Group Services
Group services (capacity > 1) have special handling:
- Multiple clients can book the same time slot
capacityfield controls max participantshasWaitlistenables waitlist when full- Staff can be assigned via
coachIdsorroundRobinTeams
Appointment Event Data Structure (Firestore)
When reading appointment documents from clinicsv2/{clinicId}/scheduled/{id}, the fields are NOT flat — they are nested. This is a common source of bugs across all platforms.
| Data | Correct Field Path | Wrong (common mistake) |
|---|---|---|
| Location name | location.name -> location.text -> locationName | locationName only |
| Location ID | location.locationId or locationId | location_id |
| Coach UID | attendees[isOrganiser=true].profileId | coachUid, organizer.uid |
| Coach name | Lookup clinician doc by UID | attendees[isOrganiser].name (may be email!) |
| Coach email | attendees[isOrganiser=true].email | — |
CRITICAL: attendee.name can be an email address.
The backend combine_name(email, firstName, lastName) falls back to email when firstName/lastName are empty. Never use attendee.name directly for display — always look up the clinician document.
Correct TypeScript pattern (used in Basis Web appointments pages):
const isEmail = (s: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s);
// Location: try all fields in order (matches Basis Flow Web dashboard)
const locationName = data.locationName || data.location?.name || data.location?.text;
// Coach: find organizer attendee
const organizer = (data.attendees || []).find((a: any) => a.isOrganiser || a.isOrganizer);
const coachUid = data.coachUid || organizer?.profileId;
// Coach name: lookup clinician doc (attendee.name may be email!)
const clinicianSnap = await getDoc(doc(db, 'clinicsv2', clinicId, 'clinicians', coachUid));
const cd = clinicianSnap.data();
const coachName = cd?.displayName?.trim()
|| `${cd?.firstName || ''} ${cd?.lastName || ''}`.trim() || undefined
|| (organizer?.name && !isEmail(organizer.name) ? organizer.name : undefined);
Basis Flow Web reference: dashboard/page.tsx -> mapAppointment() and CalendarEvent.tsx line 98 use event.location?.name and clinicians.find(c => c.uid === staffAttendee?.profileId).
Basis Web Booking Page Patterns
| Pattern | Correct Approach | Wrong |
|---|---|---|
| Available dates for date picker | Derive from get_available_service_times_enriched API response | Read availability.blocks from Firestore directly |
| Staff name (individual service) | slot.staffName from API | — |
| Staff names (group service) | slot.coaches[] -> lookup each UID in clinicians | slot.staffName (null for groups) |
| Location in slot confirmation | locationId on parent result -> lookup from clinicLocations | Not in per-slot data |
| Booking date window | userMembership.joinPolicies[].maxBookingTime | Hardcoded 7 days |
TDZ (Temporal Dead Zone) rule: Any const value used inside a useEffect dependency array MUST be defined BEFORE that useEffect in the component. The Next.js minifier will crash with Cannot access 'X' before initialization if not. Move computed values like maxBookingDays to before all hooks that reference them.
Group Series Materialization
Group services can be materialized into scheduled events using materialize_group_series. This creates visible calendar events.
Data Flow:
ImplementationModal (Basis Flow Web) -> add/update_service_implementation
| saves availability.blocks with day (1-7), start/end times
|
handleSaveImplementation -> materialize_group_series
| iterates date range, finds matching weekdays
| creates Event documents in clinicsv2/{clinic}/scheduled
Critical Date Handling:
- Frontend sends dates in UTC (e.g.,
2026-02-05T00:00:00Z) - Backend converts to location timezone for iteration
- Day matching uses ISO weekday: 1=Monday, 7=Sunday (NOT 0-6!)
- Blocks stored with
day: 4= Thursday (ISO weekday)
Common Issues:
| Symptom | Cause | Fix |
|---|---|---|
| "No sessions created" | Blocks have wrong day numbers | Check block.day is 1-7 (ISO), not 0-6 |
| "Invalid Date" in UI | Firestore Timestamp not parsed | Use safeParseDateForDisplay() helper |
| Sessions on wrong day | Timezone shift during date creation | Use UTC midnight: new Date(date + 'T00:00:00Z') |
| Location timezone null | Location missing timezone setting | Set location.timezone in Settings |
Waitlist System
When a service slot is full, clients can join a waitlist. Staff can then assign waitlisted clients to the event when spots open.
Data Flow
Client joins (Basis Hybrid/Web) -> clinicsv2/{clinic}/waitlist/{entryId}
|
Staff views in EventDetailsDrawer -> get_waitlist (returns entryId!)
|
Staff assigns/removes -> assign_from_waitlist / leave_waitlist (uses entryId)
Key Functions
| Function | Purpose | Parameters |
|---|---|---|
join_waitlist | Client joins waitlist for slot | service_id, location_id, appointment_time |
leave_waitlist | Remove from waitlist | entry_id (document ID) |
assign_from_waitlist | Staff assigns waitlisted client to event | entry_id, event_id |
get_waitlist | Get waitlist for a service/time | service_id, appointment_time |
get_client_waitlist_entries | Get all of a client's waitlist entries | client_id |
Critical Implementation Details
-
entryId is REQUIRED for assign/remove operations. Without it, operations fail.
-
Duplicate Check (in
join_waitlist):- Filters by:
clientId,serviceId,appointmentTime,status='waiting' - Does NOT filter by
locationId- intentional! Prevents waitlist hogging across locations.
- Filters by:
-
Display Query (in
get_waitlist):- Filters by:
serviceId,appointmentTime,status='waiting' - Does NOT filter by
locationId- so staff can see all entries for that time slot.
- Filters by:
-
Return Type:
get_waitlistreturnslist[dict](notlist[WaitlistEntry]) to ensureentryIdis preserved through serialization.
Storage
| Collection | Document | Fields |
|---|---|---|
clinicsv2/{clinic}/waitlist/{entryId} | Auto-generated ID | serviceId, locationId, appointmentTime, clientId, clientName, clientEmail, position, status, joinedAt |
Common Issues
| Issue | Cause | Fix |
|---|---|---|
| "Input should be a valid string" for entry_id | Frontend sending undefined | Ensure get_waitlist response includes entryId |
| Can't remove user from waitlist | entryId not fetched/displayed | Check that get_waitlist is returning entries with entryId |
| "Already on waitlist" but user not visible | Entry at different location | Fixed: display now shows all locations |
Key Functions
| Function | Purpose |
|---|---|
get_available_service_times | Returns available slots for a service/location |
get_available_service_times_enriched | Slots with membership eligibility flags |
book_appointment | Creates appointment after validation |
cancel_appointment | Cancels with late-cancel detection |
find_availability_intervals | Converts availability blocks to datetime ranges |
Complete Booking Flow
Frontend Entry Points
All three frontends call the same backend Cloud Function via httpsCallable('clinic_service') with request_type: 'book_appointment'.
┌─────────────────────┐ ┌──────────────────────┐ ┌─────────────────────────┐
│ Basis Flow Web │ │ Basis Web │ │ Basis Hybrid (Mobile) │
│ (Staff Platform) │ │ (Client Portal) │ │ │
│ │ │ │ │ │
│ BookingDrawer.tsx │ │ lib/basis.ts │ │ service_clinic_ │
│ line 619-683 │ │ line 106-133 │ │ storage.dart:508-531 │
│ │ │ │ │ │
│ Modes: slots | │ │ bookAppointment() │ │ bookAppointment() │
│ manual │ │ │ │ │
└────────┬────────────┘ └──────────┬───────────┘ └────────────┬────────────┘
│ │ │
└──────────────────────────┼────────────────────────────┘
│
httpsCallable('clinic_service')
│
┌──────────────▼──────────────┐
│ clinic_service dispatcher │
│ functions_clinic.py:9806 │
│ │
│ 1. Auth check │
│ 2. REQUEST_MODEL_MAP lookup │
│ 3. Pydantic validation │
│ 4. Permission check │
│ 5. Call handler │
└──────────────┬──────────────┘
│
┌──────────────▼──────────────┐
│ book_appointment() │
│ functions_clinic.py:5168 │
│ (see validation chain) │
└─────────────────────────────┘
Request Payload (all frontends send the same shape)
{
"request_type": "book_appointment",
"clinic_id": "clinic-id-here",
"request": {
"serviceId": "service-id",
"locationId": "location-id",
"time": "2026-03-05T14:00:00.000Z",
"duration": { "s": 3600 },
"clients": ["client-uid-1"],
"coaches": ["coach-uid-1"],
"bookedBy": "caller-uid",
"note": null,
"sendConfirmationEmail": true
},
"adminOverride": false
}
Duration format: { "s": seconds } — Dart's Duration.toJson() produces { "s": N }. The Pydantic Duration model validates this format.
REQUEST_MODEL_MAP Entry (line 9670)
'book_appointment': (book_appointment, BookAppointmentClinicRequest, [], 'all')
# handler request_model perms perm_type
Permissions [] means no staff permission required — clients can self-book from the portal/mobile app.
Cross-File Dependency Map
CRITICAL: When modifying ANY of these files, you MUST verify consistency with ALL its dependencies before deploying. A mismatch between files that are deployed together causes production crashes.
functions_clinic.py ─────────────────── THE MONOLITH (10,000+ lines)
├── imports from enums.py ────────── ClinicUserStatus, EventType, EventStatus, AttendeeRole
├── imports from model_clinic.py ─── BookAppointmentRequest, ClinicUserDetails, MembershipUsage
├── imports from model_event.py ──── Event, Attendee, SummaryObjectPosition
└── imports from functions_permissions.py ── permission checks
model_clinic.py ─────────────────────── Pydantic request/response models
└── imports from enums.py ────────── ClinicUserStatus, ClinicTransactionType
model_event.py ──────────────────────── Event model
└── imports from enums.py ────────── EventType, EventStatus, AttendeeRole, Availability
Co-Deployment Rule
All Python files in basis-functions/functions/src/ are deployed as a single unit via:
firebase deploy --only functions:clinic_service --force
This deploys ALL .py files together. However, if your working tree has inconsistent edits (e.g., you added a reference to ClinicUserStatus.TRIAL in functions_clinic.py but haven't yet added TRIAL to the enum in enums.py), the deploy will ship broken code.
Pre-deploy checklist for booking changes:
- Every enum member referenced in
functions_clinic.pyexists inenums.py - Every Pydantic model field used in
functions_clinic.pyexists inmodel_clinic.py/model_event.py - Frontend sends all required fields that the Pydantic model expects
- If adding a new status/enum value, ALL files referencing that enum are updated
book_appointment Validation Chain
Every step in book_appointment() (line 5168-5742) that can reject a booking, in execution order:
| Step | Line | Check | Error Code | Error Message |
|---|---|---|---|---|
| 1 | 5195 | Service exists in clinic | not-found | Service not found |
| 2 | 5198 | Coaches provided (non-group) | failed-precondition | Coaches required |
| 3 | 5206 | Clients provided (non-staff-unavailable, non-admin) | failed-precondition | Clients required |
| 4 | 5222 | Location exists in clinic | not-found | Location not found |
| 5 | 5226 | Client docs exist in Firestore | not-found | Client not found |
| 6 | 5232 | Clinician docs exist in Firestore | not-found | Clinician not found |
| 7 | 5262 | Client status is ACTIVE or TRIAL | permission-denied | Your account is currently {status} |
| 8 | 5272 | No blocking pending onboarding | failed-precondition | Complete onboarding first |
| 9 | 5295 | Membership allows this service | various | See membership validation |
| 10 | 5306 | Within booking time window | failed-precondition | Too early/late to book |
| 11 | 5338 | Coach not unavailable (V2 rules) | failed-precondition | Coach unavailable at this time |
| 12 | 5397 | No holiday closure at location | failed-precondition | Clinic closed (holiday) |
| 13 | 5482 | No time conflicts (coach or client) | already-exists | Appointment time is already booked |
| 14 | 5418 | Group service capacity (if group) | resource-exhausted | Session is full |
Membership Validation (Step 9 detail)
validate_membership_booking_restrictions() (line 10285-10447) runs these sub-checks:
| Sub-step | Check | Error |
|---|---|---|
| 9a | Package session limit not exceeded | resource-exhausted |
| 9b | Trial eligibility (if trial, skip 9c-9d) | — |
| 9c | Credit pool has credits (if credit-based) | resource-exhausted |
| 9d | Free bookings not exceeded for billing period | resource-exhausted |
| 9e | Location in accessible locations list | permission-denied |
| 9f | Within min/max booking time window | failed-precondition |
Key Models Reference
BookAppointmentRequest (model_clinic.py:337)
class BookAppointmentRequest(BaseModel):
serviceId: str # Must match a service in clinic doc
locationId: str # Must match a location in clinic doc
time: datetime # ISO 8601 UTC
duration: Duration # { "s": seconds }
clients: list[str] # List of client UIDs
coaches: list[str] = [] # List of clinician UIDs
bookedBy: str # UID of the caller
note: Optional[str] # Free text
sendConfirmationEmail: bool = True
ClinicUserStatus (enums.py:418)
class ClinicUserStatus(BasisEnum):
ACTIVE = (0, 'active', 'Active')
INACTIVE = (1, 'inactive', 'Inactive')
PAUSED = (2, 'paused', 'Paused')
TRIAL = (3, 'trial', 'Trial')
Booking rule: Only ACTIVE and TRIAL can book. Referenced at functions_clinic.py:5262 and :10888 (waitlist).
ClinicUserDetails (model_clinic.py:831)
Key fields affecting booking:
status: ClinicUserStatus— must be ACTIVE/TRIALmembershipType: Optional[str]— links to clinic membership definitionmembershipUsage: Optional[MembershipUsage]— tracks trial/credit/package consumptionpendingOnboarding: Optional[PendingOnboarding]— can block booking ifblocksBooking=truelastAppointment: Optional[datetime]— updated after successful booking
Common Failure Modes
| Failure | Root Cause | How to Detect | Prevention |
|---|---|---|---|
| AttributeError on enum | Enum member referenced but not defined (e.g., ClinicUserStatus.TRIAL missing) | All bookings fail with 500. Error in Cloud Function logs: AttributeError: TRIAL | Always verify enum members exist before deploying |
| Partial deploy | functions_clinic.py deployed with new references but enums.py not updated | Same as above — cross-file mismatch | Use pre-deploy checklist above |
| Pydantic validation error | Frontend sends new field, backend has old model (or vice versa) | "invalid request data" 400 error | Redeploy backend after model changes |
| Gender.charAt crash | Firestore stores gender as number (1/2 from Flutter), but code calls .charAt() | TypeError: p.gender.charAt is not a function | Always coerce with String(value) before string methods |
| Credit pool blocks booking | Credit pool exhausted but regular free bookings still available | resource-exhausted even though member has sessions left | Credit pool check must fall through to regular benefits |
| Missing duration | Duration sent as { "s": N } but model expects different format | Pydantic validation error | Duration model handles { "s": N } — verify Pydantic Duration validator |
| Client filter hides clients | Frontend filters out inactive/paused clients from dropdown | Staff can't find client to book | Show all clients, sort active first |
| safeOnSnapshot hides errors | Permission-denied errors silently swallowed | Data appears to not load, no error shown | Check Firestore rules, redeploy if changed |
| CORS failure | localhost:3000 missing from function's CORS origins | 403 in browser console during local dev | Verify CORS_ORIGINS includes localhost |
Post-Booking Side Effects
After successful booking, these operations run (non-blocking):
- Consume membership usage (
_consume_membership_usage): Deducts from package sessions, trial bookings, or credit pool - Update lastAppointment: Sets
lastAppointmenton each client's clinic_users doc - Send confirmation email: iCal attachment + HTML email via
send_appointment_confirmation_email() - Push notifications: Via
send_appointment_booking_notifications() - Audit log:
ClinicTransactionwithBookAppointmentTransactionDatawritten totransactionscollection