Skip to main content
Synced from the repo — do not edit here

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

  1. Location Availability — Base hours for location
  2. Clinician Availability — Per-staff schedule
  3. Service Implementation — When service offered at location
  4. Blocked Intervals — Holidays, PTO, breaks

Final available slots = intersection of (1, 2, 3) minus (4).

Key Concepts

ConceptDescriptionStorage
LocationPhysical clinic location with address, timezone, base availabilityclinicsv2/{clinic}.locations[]
ServiceType of appointment (e.g., "Office Visit", "Telehealth")clinicsv2/{clinic}.services[]
Service ImplementationHow a service is offered at a specific locationlocation.services[]
Availability BlockRecurring weekly availability (day, start, end, location)clinician.availability.blocks[]
AvailabilityV2Per-clinician recurring rulesclinicsv2/{clinic}/clinicians/{uid}/availabilityV2
Blocked IntervalsExact time ranges when unavailableclinician.blockedIntervals[]
HolidaysClinic-wide closuresclinicsv2/{clinic}/holidays/{id}

Membership Constraints

Memberships control what clients can book:

ConstraintFieldDescription
Service Accessbenefits[].serviceIdWhich services membership includes
Free Bookingsbenefits[].freeBookingsNumber of free sessions per period
Booking Window (min)joinPolicies[].minBookingTimeHow far in advance required
Booking Window (max)joinPolicies[].maxBookingTimeHow far ahead allowed
Location AccessaccessibleLocationIdsWhich locations member can use
Cancellation PolicyjoinPolicies[].cancellationPolicyWhen cancellation is "late"
Credit PoolcreditPoolConfigCapacity-based booking limits
Package LimitstotalSessionLimitTotal 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
  • capacity field controls max participants
  • hasWaitlist enables waitlist when full
  • Staff can be assigned via coachIds or roundRobinTeams

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.

DataCorrect Field PathWrong (common mistake)
Location namelocation.name -> location.text -> locationNamelocationName only
Location IDlocation.locationId or locationIdlocation_id
Coach UIDattendees[isOrganiser=true].profileIdcoachUid, organizer.uid
Coach nameLookup clinician doc by UIDattendees[isOrganiser].name (may be email!)
Coach emailattendees[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

PatternCorrect ApproachWrong
Available dates for date pickerDerive from get_available_service_times_enriched API responseRead availability.blocks from Firestore directly
Staff name (individual service)slot.staffName from API
Staff names (group service)slot.coaches[] -> lookup each UID in cliniciansslot.staffName (null for groups)
Location in slot confirmationlocationId on parent result -> lookup from clinicLocationsNot in per-slot data
Booking date windowuserMembership.joinPolicies[].maxBookingTimeHardcoded 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:

SymptomCauseFix
"No sessions created"Blocks have wrong day numbersCheck block.day is 1-7 (ISO), not 0-6
"Invalid Date" in UIFirestore Timestamp not parsedUse safeParseDateForDisplay() helper
Sessions on wrong dayTimezone shift during date creationUse UTC midnight: new Date(date + 'T00:00:00Z')
Location timezone nullLocation missing timezone settingSet 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

FunctionPurposeParameters
join_waitlistClient joins waitlist for slotservice_id, location_id, appointment_time
leave_waitlistRemove from waitlistentry_id (document ID)
assign_from_waitlistStaff assigns waitlisted client to evententry_id, event_id
get_waitlistGet waitlist for a service/timeservice_id, appointment_time
get_client_waitlist_entriesGet all of a client's waitlist entriesclient_id

Critical Implementation Details

  1. entryId is REQUIRED for assign/remove operations. Without it, operations fail.

  2. Duplicate Check (in join_waitlist):

    • Filters by: clientId, serviceId, appointmentTime, status='waiting'
    • Does NOT filter by locationId - intentional! Prevents waitlist hogging across locations.
  3. 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.
  4. Return Type: get_waitlist returns list[dict] (not list[WaitlistEntry]) to ensure entryId is preserved through serialization.

Storage

CollectionDocumentFields
clinicsv2/{clinic}/waitlist/{entryId}Auto-generated IDserviceId, locationId, appointmentTime, clientId, clientName, clientEmail, position, status, joinedAt

Common Issues

IssueCauseFix
"Input should be a valid string" for entry_idFrontend sending undefinedEnsure get_waitlist response includes entryId
Can't remove user from waitlistentryId not fetched/displayedCheck that get_waitlist is returning entries with entryId
"Already on waitlist" but user not visibleEntry at different locationFixed: display now shows all locations

Key Functions

FunctionPurpose
get_available_service_timesReturns available slots for a service/location
get_available_service_times_enrichedSlots with membership eligibility flags
book_appointmentCreates appointment after validation
cancel_appointmentCancels with late-cancel detection
find_availability_intervalsConverts 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.py exists in enums.py
  • Every Pydantic model field used in functions_clinic.py exists in model_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:

StepLineCheckError CodeError Message
15195Service exists in clinicnot-foundService not found
25198Coaches provided (non-group)failed-preconditionCoaches required
35206Clients provided (non-staff-unavailable, non-admin)failed-preconditionClients required
45222Location exists in clinicnot-foundLocation not found
55226Client docs exist in Firestorenot-foundClient not found
65232Clinician docs exist in Firestorenot-foundClinician not found
75262Client status is ACTIVE or TRIALpermission-deniedYour account is currently {status}
85272No blocking pending onboardingfailed-preconditionComplete onboarding first
95295Membership allows this servicevariousSee membership validation
105306Within booking time windowfailed-preconditionToo early/late to book
115338Coach not unavailable (V2 rules)failed-preconditionCoach unavailable at this time
125397No holiday closure at locationfailed-preconditionClinic closed (holiday)
135482No time conflicts (coach or client)already-existsAppointment time is already booked
145418Group service capacity (if group)resource-exhaustedSession is full

Membership Validation (Step 9 detail)

validate_membership_booking_restrictions() (line 10285-10447) runs these sub-checks:

Sub-stepCheckError
9aPackage session limit not exceededresource-exhausted
9bTrial eligibility (if trial, skip 9c-9d)
9cCredit pool has credits (if credit-based)resource-exhausted
9dFree bookings not exceeded for billing periodresource-exhausted
9eLocation in accessible locations listpermission-denied
9fWithin min/max booking time windowfailed-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/TRIAL
  • membershipType: Optional[str] — links to clinic membership definition
  • membershipUsage: Optional[MembershipUsage] — tracks trial/credit/package consumption
  • pendingOnboarding: Optional[PendingOnboarding] — can block booking if blocksBooking=true
  • lastAppointment: Optional[datetime] — updated after successful booking

Common Failure Modes

FailureRoot CauseHow to DetectPrevention
AttributeError on enumEnum member referenced but not defined (e.g., ClinicUserStatus.TRIAL missing)All bookings fail with 500. Error in Cloud Function logs: AttributeError: TRIALAlways verify enum members exist before deploying
Partial deployfunctions_clinic.py deployed with new references but enums.py not updatedSame as above — cross-file mismatchUse pre-deploy checklist above
Pydantic validation errorFrontend sends new field, backend has old model (or vice versa)"invalid request data" 400 errorRedeploy backend after model changes
Gender.charAt crashFirestore stores gender as number (1/2 from Flutter), but code calls .charAt()TypeError: p.gender.charAt is not a functionAlways coerce with String(value) before string methods
Credit pool blocks bookingCredit pool exhausted but regular free bookings still availableresource-exhausted even though member has sessions leftCredit pool check must fall through to regular benefits
Missing durationDuration sent as { "s": N } but model expects different formatPydantic validation errorDuration model handles { "s": N } — verify Pydantic Duration validator
Client filter hides clientsFrontend filters out inactive/paused clients from dropdownStaff can't find client to bookShow all clients, sort active first
safeOnSnapshot hides errorsPermission-denied errors silently swallowedData appears to not load, no error shownCheck Firestore rules, redeploy if changed
CORS failurelocalhost:3000 missing from function's CORS origins403 in browser console during local devVerify CORS_ORIGINS includes localhost

Post-Booking Side Effects

After successful booking, these operations run (non-blocking):

  1. Consume membership usage (_consume_membership_usage): Deducts from package sessions, trial bookings, or credit pool
  2. Update lastAppointment: Sets lastAppointment on each client's clinic_users doc
  3. Send confirmation email: iCal attachment + HTML email via send_appointment_confirmation_email()
  4. Push notifications: Via send_appointment_booking_notifications()
  5. Audit log: ClinicTransaction with BookAppointmentTransactionData written to transactions collection