Canonical source: docs/claude/scheduling-booking-audit.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 & Booking Audit
Authoritative map of how basis-functions/functions/src/functions_clinic.py
(clinic_service + supporting helpers) and the consuming UIs enforce every
rule in the booking pipeline.
Status legend for each item below:
- ✅ Verified — backed by a specific file:line citation read this session.
- 🔍 Needs verification — claim is plausible from the code I've read but not yet confirmed end-to-end. Treat as a hypothesis until upgraded.
- ⚠️ Bug / inconsistency — verified gap between intended behavior and actual code. Tracked for fix.
Goal of this doc: so that every fix in this area can be made with explicit awareness of which other rules it touches, and which surfaces consume the result. Updated as we work — not a one-shot snapshot.
1. Three-phase enforcement model
The booking system enforces rules at three distinct moments. Knowing which rule fires at which moment matters because a rule enforced only at one phase has a different failure mode than a rule enforced at all three.
| Phase | Entry point | What it does | Failure mode if a rule is enforced here |
|---|---|---|---|
| List | get_available_service_times (functions_clinic.py:5342) | Returns the slot grid the client renders. Filters/decorates slots based on most rules. | If only enforced here: client can bypass by calling book_appointment directly. UI hides slots. |
| Book | validate_membership_booking_restrictions (line 13966) called from book_appointment_transaction (line 7306 via 7407) | Final validation server-side. Throws on violation. | If only enforced here: slot appears bookable to the user but the booking call errors. Bad UX. |
| Reschedule | client_reschedule_appointment handler → update_event_details cross-booking re-check (line 12597–12650) | When moving an existing booking. | Independent rule set from List/Book — has its own checks (currently only cross-booking). |
The healthy pattern is "rule enforced at both List and Book" — List for UX, Book for safety. Several rules are missing from one or the other (see §10).
2. Surfaces that consume this
| Surface | Reads | Path |
|---|---|---|
| basishybrid (mobile, Flutter) | Slot list via getAvailableServiceTimes storage method (basiscoreui), parses tuples (locationId, BasisInterval, staffId, isAvailable). Renders grid in schedule.dart. | hybrid/basishybrid/lib/view/routes/schedule/schedule.dart |
| basisweb (client portal, Next.js) | Same endpoint, different consumer. Per memory reference_basisweb_deploy_branch deploys from feat/basisweb branch. | hybrid/basisweb/ |
| basisflow-web (staff platform) | Reads slot grid for staff-on-behalf bookings (via target_uid param). Also renders client schedules from Firestore mirrors directly (PlanTab.tsx etc.). | hybrid/basisflow-web/app/(main)/clients/[id]/components/PlanTab.tsx |
Cross-booking display info (blockingRule map + blockedByCrossBooking flag)
is delivered through the same endpoint when include_cross_booking_blocked: true is passed (basiscoreui already does this; basisweb still pending — task
#17).
3. Slot listing flow — get_available_service_times
Located at functions_clinic.py:5342. Reads the request, fans out parallel I/O, then per-location iterates clinicians to generate slot candidates and applies a series of filters.
3a. Inputs
✅ Request (GetAvailableServiceTimesRequest, line ~605):
clinic_id,service_id,location_ids(list, optional)start,end(datetime range to compute slots for)include_cross_booking_blocked: bool = False(line 611) — opt-in flagtarget_uid: Optional[str](line 604) — explicit override for who the caller is computing slots FOR. Defaults toby.uidat line 5377.exclude_event_id: Optional[str]— set on reschedule to net-zero the rescheduled event from the cross-booking count.
3b. Parallel I/O fan-out (line 5380–5487)
✅ Single ThreadPoolExecutor(max_workers=6) runs:
- Clinic doc fetch
- Permission fetch (resolves
is_stafffor caller) - Scheduled events query (
status in [none, confirmed, tentative, …],start ∈ [request.start − 2 days, request.end]) (line 5381–5384) - Cross-booking docs (
preferences/memberships,preferences/crossBooking) — only fetched iftarget_uid && include_cross_booking_blocked(_needs_cb_docsat line 5379)
⚠️ Bug — scheduled-appointments window too narrow for cross-period rule
checking (line 5383): start >= request.start − 2 days. For a JULY slot
listing query, June 1–28 bookings are NOT in scheduled_appointments. The
cross-booking count loop at line 5731–5747 iterates this list — so past
months' usage of a "max N per month" rule isn't visible. Doesn't cause the
reported June→July bug (that's in the credit pool path, §5c), but it means
"max N per year" rules can be wrongly empty when querying a January slot for
a user who already used the cap in the previous year. Tracked as gap.
3c. Staff-vs-client filtering (line 5407–5419)
✅ is_staff = perm.has_any_permission([EDIT_SCHEDULE, CLINIC_ADMIN, ALL]).
Used downstream at line 5436, 5447 to skip disabled service implementations for non-staff. Staff sees disabled implementations too. ⚠️ This is also the gate for the "no coach slots → use service availability fallback" at line 5892 — clients DON'T get the fallback, staff DOES. Doesn't affect the reported bugs but is worth knowing because it can produce the "server-rendered slot grid differs by caller role" effect.
3d. Cross-booking rule resolution (line 5527–5552)
✅ The active code path. Reads:
preferences/memberships.crossBookingRuleIdByMembershipId(line 5531)clinic_users/{target_uid}.membershipType(line 5535–5537)- Looks up rule_id from mapping; loads candidate from
preferences/crossBooking.rules(line 5540) - Validates: rule active,
request.service_id ∈ rule.serviceIds,limitis a positive int (line 5541–5547) - Sets
applicable_rule+rule_service_idsif all checks pass
A second commented-out copy of this same code exists at line 5683–5709 ("ORIGINAL SEQUENTIAL CODE — UNCOMMENT TO REVERT"). It's inside a triple-quoted docstring at line 5561–5710. Verified inactive. Don't edit it thinking you're changing live behavior.
3e. Period bucketing (line 5320 — _compute_period_key)
✅ For a UTC datetime + interval + tz_id, returns:
day(default) →YYYY-MM-DDweek→WEEK-{monday_YYYY-MM-DD}month→YYYY-MMyear→YYYY
Computed in the location's TZ when provided, else UTC. June and July get
different keys (2026-06 vs 2026-07). ✅
3f. Per-location loop — slot candidate generation
🔍 Located at line ~5712 onward. Per (location, service_impl) pair, the
loop:
- Resolves TZ for the location.
- Computes
blocked_period_keysfor cross-booking (line 5727–5747). - Builds
_cb_stamp_payload_basefor slot stamping (line 5762–5770). - Computes holiday closures (line 5773–5778 —
_holiday_spans_for_window). - For each clinician, generates raw slot candidates from their availability intervals (need to read line 5780–5900 to fully document).
- For each candidate slot, applies:
- Cross-booking filter (line 6046–6069): if slot's period is in
blocked_period_keys, drop OR mark blocked depending oninclude_cross_booking_blocked. - Group service handling (line 6072–6102): capacity + coach coverage checks.
- Individual service handling (line 6103–6169): coach conflict check.
- Waitlist branch (line 6164–6169): if not directly bookable but service has waitlist AND it's a same-service conflict, keep as waitlistable.
- Cross-booking filter (line 6046–6069): if slot's period is in
- Renders each kept slot into output dict (line 6182–6229).
⚠️ §4–§8 below have the detail and known gaps for each rule.
3g. Output shape (line 6231–6237)
✅ Per-location result:
{
'locationId': str,
'availableSlots': [...], # only slots with is_available == True
'slots': [...], # ALL kept slots, with 'available': bool
'closures': [...] # holiday metadata
}
Each slot dict (line 6193–6229):
start,end(ISO strings, UTC)staff(uid) or null for groupstaffNameavailable: bool(inslotsonly)blockedByCrossBooking: true(only wheninclude_cross_booking_blockedAND slot is in blocked period)blockingRule: {name, interval, limit, used, serviceIds}(alongsideblockedByCrossBooking, line 6213–6216)- For group:
coaches: [uid](line 6224–6226)
🔍 The frontend tuple shape (locationId, BasisInterval, staffId?, available)
is built by basiscoreui's getAvailableServiceTimes from locationData['slots'].
4. Booking validation flow — validate_membership_booking_restrictions
Located at line 13966. Called from book_appointment_transaction (line 7407)
and a second site (line 15172 — needs identification).
Five-step priority chain. First success short-circuits (lines 14033,
14043 use return); first failure raises.
4a. Step 1 — Package limits (line 14019–14026)
✅ If user_membership.isPackage: call _validate_package_limits(user, membership).
🔍 Need to read _validate_package_limits to document.
4b. Step 2 — Trial bookings (line 14028–14033)
✅ Call _validate_trial_eligibility(user, membership, service_id). If trial
available → log + return (skip all remaining checks).
🔍 Need to read _validate_trial_eligibility and confirm:
- Does it have a period concept? (If trials reset monthly etc.)
- Does it consult appointment_time?
4c. Step 3 — Credit pool (line 14035–14060)
✅ Call _validate_credit_pool_availability(user, membership, service_id).
⚠️ BUG (this is the June→July issue you reported):
_validate_credit_pool_availability (line 13756–13793) computes:
credits_remaining = pool.creditsPerPeriod - usage.creditsUsedThisPeriod
creditsUsedThisPeriod is a denormalized counter on the user's membership
usage doc — it tracks the user's CURRENTLY-ACTIVE billing period. The
appointment time is never consulted. Effects:
- Today is June 20, user has used 4/4 credits in June.
- Booking July 4: check uses
creditsUsedThisPeriod = 4→0 < 1→ BLOCKED. - Once July starts, the period rolls over → counter resets → user can book.
Compare with §4d which correctly anchors on appointment_time.
Fix path (F1 in plan): make _validate_credit_pool_availability count
scheduled events in the appointment's period (mirror what
_get_user_service_usage_count does at line 14445).
4d. Step 4 — Free bookings benefit (line 14062–14090)
✅ For the matching benefit:
usage_count = _get_user_service_usage_count(
...
interval=service_benefit.freeBookingsInterval,
period_anchor=booking_time, # ← receives appointment time from callers
timezone_id=tz_id,
)
if usage_count >= service_benefit.freeBookings: raise
The booking_time parameter is misnamed at line 13973: defaults to
utcnow() but both call sites (line 7414 with request.time, line 15174
with event.start) pass the appointment time. _get_user_service_usage_count
correctly buckets by the appointment's period (line 14496–14500). ✅
Works correctly for "max N per month" benefits via this path.
⚠️ Naming hazard: future call sites might pass actual "now" assuming the parameter name, breaking the semantic. Tracked as renaming opportunity.
4e. Step 5 — Location access (line 14092–14098)
✅ if location_id not in user_membership.accessibleLocationIds: raise.
4f. Step 6 — Booking time constraints (line 14100–14127)
🔍 Looks up applicable_policy from user_membership.joinPolicies (line
14106–14113). Then min/maxBookingTime are logged but NOT enforced inside
validate_membership_booking_restrictions. A separate function
validate_booking_time_constraints (line 14131) appears to do the actual
enforcement.
⚠️ Where is validate_booking_time_constraints called from? Needs grep.
If it's NOT wired into the booking flow, min/max booking time is unenforced
server-side — only the frontend's slot grid filter (basishybrid schedule.dart
line 1494–1539) applies it. That'd mean a client can bypass with a direct
API call.
5. Reschedule validation
✅ Handler client_reschedule_appointment (deployed) wraps update_event_details.
✅ Cross-booking check at line 12597–12650 — counts user's other bookings
matching the rule's services that fall in the new slot's period (excluding
the rescheduled event via exclude_event_id). Raises if at or over limit.
⚠️ What update_event_details does NOT re-validate (gap inventory needed):
- Membership benefit limits (free bookings, credit pool, trial, package)?
- Location/service access?
- Min/max booking window?
- Group capacity?
🔍 Need to grep the reschedule path to confirm. If any of these aren't re-checked, a user can reschedule into a state they couldn't have booked into directly.
6. Rule-by-rule reference
(Each section: where defined → where enforced (list/book/reschedule) → caveats.)
6a. Membership benefits — freeBookings per freeBookingsInterval
- Defined:
MembershipServiceBenefitsonMembershipType.benefits[]. - List enforcement: 🔍 not yet confirmed — does the slot grid hide slots
in periods where the user is at their
freeBookingscap? - Book enforcement: ✅ Step 4 of
validate_membership_booking_restrictionsvia_get_user_service_usage_count(correct, anchors on appointment time). - Reschedule: 🔍 unconfirmed.
6b. Credit pool — creditPoolConfig.creditsPerPeriod
- Defined:
MembershipType.creditPoolConfig. - List enforcement: 🔍 likely not enforced at list time today.
- Book enforcement: ⚠️ Step 3 of
validate_membership_booking_restrictionsvia_validate_credit_pool_availability— buggy (uses denormalized counter, not appointment-period count). See §4c. - Reschedule: 🔍 unconfirmed.
6c. Trial — _validate_trial_eligibility
🔍 Needs read. Period-aware or not?
6d. Package — isPackage, totalSessionLimit
🔍 Needs read of _validate_package_limits. Tracks totalSessionsUsed (line
13846) — is this an appointment-period concept or a lifetime cap?
6e. Cross-booking — preferences/crossBooking rules
- List enforcement: ✅ at line 5727–5747 (count bookings in window per period) + 6056–6069 (drop or stamp per slot). ⚠️ Count window too narrow for past-period rules — see §3b.
- Book enforcement: ❓ I don't see a cross-booking check inside
book_appointment(grep earlier confirmed). Means a client at quota CAN bypass via direct API call. The slot listing hides the slot but the book endpoint accepts it. - Reschedule: ✅ at line 12597–12650.
⚠️ Genuine gap in book_appointment — needs fix or confirmation it's
intentional. Tracked.
6f. Min/Max booking window — joinPolicies.minBookingTime / maxBookingTime
- Defined:
MembershipType.joinPolicies[].minBookingTime/maxBookingTime. - List enforcement: 🔍 only in the client-side filter (schedule.dart:1494–1539). Backend may also filter or just log.
- Book enforcement: 🔍 needs grep for
validate_booking_time_constraintscall sites. - Reschedule: 🔍 unconfirmed.
6g. Slot release time — joinPolicies.slotReleaseTime
🔍 Referenced in schedule.dart:1522–1539. Reduces effective max-days when "today's release time hasn't passed yet." Backend behavior unconfirmed.
6h. Service access — benefit-based
✅ Checked at line 6878–6880 (slot listing) and via service_benefit lookup
in step 4 of booking validation. ✅ Membership-less users can book public
services (line 14486).
6i. Location access — accessibleLocationIds
- List: 🔍 schedule.dart:1474–1483 filters client-side. Backend unclear.
- Book: ✅ Step 5 of
validate_membership_booking_restrictions(line 14093). - Reschedule: 🔍 unconfirmed.
6j. Group services — capacity + coach coverage
🔍 Per slot loop at line 6072–6102:
- Counts non-coach REQUIRED attendees against
service.capacity. - "Has coverage" = ≥1 assigned coach is free at the slot.
is_available = participants_below_cap AND has_coverage.- Adds slot if available OR (waitlist enabled AND has coverage).
⚠️ Reported bug — group bookable when no coaches available. The
condition is (participant_count < capacity) AND has_primary_coverage
(line 6098). If _coach_conflicts returns true for ALL assigned coaches
(no coverage), has_primary_coverage = false, is_available = false. But
the second branch (service.hasWaitlist AND has_primary_coverage) ALSO
requires has_primary_coverage. So no-coverage groups should be hidden.
Need to confirm the actual bug shape — maybe it's that has_primary_coverage
is being computed incorrectly (e.g., coaches with overlapping bookings are
still considered "available"), or basishybrid client is overriding the
filter and showing the slot anyway.
⚠️ Reported bug — only one coach is shown for groups. The slot is
emitted with coach = None (line 6102) and a separate coaches array
attached at line 6224–6226. If basishybrid's grid renders slot.$3 as the
coach (which is null for groups), only one coach name would appear if the
UI falls back to a default. Or the coaches array isn't being read on the
client side.
🔍 Need to look at basishybrid render path for group slots.
6k. Waitlist
- Defined:
service.hasWaitlist: boolonClinicService. - List enforcement: ✅ in slot loop, if
is_available == falsebut service has waitlist AND the conflict is a same-service booking (line 6141–6169), the slot is kept withis_available = falsefor waitlist UX rendering. - Book enforcement: 🔍 separate handler for waitlist join — needs grep.
- Reschedule: 🔍 unconfirmed.
⚠️ Reported bug — "Only available" filter not showing all bookable
events. The basishybrid filter at schedule.dart:1434 sets
includeWaitlist = service.hasWaitlist && !_onlyAvailable. Then line 1442:
isBookableOrWaitlistable = isAvailable || includeWaitlist || isCrossBookingBlocked.
When _onlyAvailable == true, includeWaitlist = false, so only
isAvailable slots remain. If some bookable slots are coming back from the
backend with available: false for waitlist (rather than available: true),
the toggle would hide them. Possible mismatch in the meaning of "available."
🔍 Need to verify what the backend marks available: true vs false for
slots that ARE directly bookable.
6l. Clinician availability + blocked intervals
🔍 Need to read line 5780–5900 to document how each clinician's bookable intervals are computed:
ClinicianDetails.availability— recurring weekly schedule?ClinicianDetails.blockedIntervals— one-off block-outs?- Cross-cut with scheduled appointments to subtract busy times.
6m. Holiday closures
🔍 _holiday_spans_for_window (line ~5773) returns intervals subtracted
from clinic availability. Need to read it.
7. Frontend filters
7a. basishybrid (widget_section_events.dart + schedule.dart)
✅ schedule.dart:1431–1442 filter chain (post-fetch):
correctLocation(location must match)isAvailable || includeWaitlist || isCrossBookingBlocked— bookable familyhasCoachOrWaitlistMode(group exception)matchesDateFilter(selected day filter)matchesCoachFilter(selected coach filter)hasLocationAccess(membership-permitted location)hasServiceAccess(membership covers this service OR public service)passesTimeWindow(joinPolicies min/max + slotReleaseTime)
✅ Home filter at widget_section_events.dart drops events with
completionStatus == skipped, and a separate dedup that hides summary
events that a habit references via autocompletedBy.
7b. basisweb (client portal)
🔍 Not yet read. Task #17 (basisweb consume blockingRule) acknowledges it
doesn't yet match basishybrid's cross-booking display.
7c. basisflow-web (staff platform)
🔍 PlanTab.tsx renders client schedules. Doesn't filter by membership rules (staff sees all bookings). Has its own dedup logic for habit↔summary pairs which is being investigated for the visible duplicates (Walk/Strength/Sauna).
8. Known gaps & inconsistencies (running list)
8a. Surface-classified table (all open items)
Surface column key:
- BE = backend (Cloud Function in
basis-functions/functions/src/) - FE-hybrid = basishybrid (mobile Flutter)
- FE-web = basisweb (client portal Next.js)
- FE-flow = basisflow-web (staff platform Next.js)
- BOTH = needs coordinated change in BE + at least one FE
| # | Issue | Surface | Where | Status |
|---|---|---|---|---|
| Scheduling/booking gaps (this audit) | ||||
| G1 | Credit pool uses denormalized counter, not appointment-period count | BE | _validate_credit_pool_availability:13788 | ⚠️ confirmed; F1 fix queued |
| G2 | Scheduled-appts window start−2d → past-period rules under-count | BE | get_available_service_times:5383 | ⚠️ confirmed; affects "max N per year" |
| G3 | book_appointment has no cross-booking validation server-side | BE | inside book_appointment_transaction (line 7306+) | ⚠️ confirmed; client can bypass at-quota |
| G4 | validate_booking_time_constraints may not be called from booking flow | BE | line 14131 — call-site grep pending | 🔍 needs grep |
| G5 | Reschedule re-validates only cross-booking, not benefits/credit/etc. | BE | update_event_details — full audit pending | 🔍 needs grep |
| G6 | booking_time param misnamed (callers actually pass appointment time) | BE | line 13973 | ⚠️ confirmed; rename opportunity |
| G7 | Group service: "only one coach shown" | FE-hybrid (likely) | basishybrid render uses slot.$3 not slot.coaches? | 🔍 reported, not yet traced |
| G8 | Group service: "bookable when no coaches available" | BOTH (likely BE) | line 6098 logic looks correct on paper; needs runtime data | 🔍 reported, not yet traced |
| G9 | "Only available" filter hides bookable slots | FE-hybrid OR BE | schedule.dart:1442 if BE marks direct-bookable as available:false | 🔍 reported, not yet traced |
| Cross-booking display bug | ||||
| X1 | At-quota client sees no slots instead of slots-with-badge (mobile) | FE-hybrid (likely) — race/TZ or missing block set | schedule.dart:1366-1442 + backend stamp at 6210 | 🔍 needs runtime data |
| X2 | At-quota client sees no slots instead of slots-with-badge (web) | FE-web (task #17) | basisweb hasn't ported the blockingRule consumer | ⚠️ confirmed pending; task #17 |
| Other 1.7.65 candidates | ||||
| R1 | Reschedule shows coach UIDs instead of names | FE-hybrid ✅ already fixed in working tree | modal_reschedule.dart:_formatCoachName displayName fallback | ✅ done; ships next IPA |
| R2 | Sauna autocompletedBy=1 placeholder | FE-hybrid | service_timer.dart:saveTimer returns object with INITIAL_ID | ⚠️ confirmed; 15-min fix |
| R3 | Habit↔summary visible duplicates (Walk, Strength, Sauna) | BOTH (FE-hybrid + FE-flow) | render-side dedup by time-overlap in PlanTab + widget_section_events | ⚠️ confirmed; both surfaces |
| Sleep duplicate (Terra writer consolidation) | ||||
| T1 | Two writers (BE terra_adapter + basishybrid client) produce duplicate sleep docs | BOTH (BE + FE-hybrid) | terra_adapter.py:1056 + service_health_firestore_writer.dart:3104 | ⚠️ confirmed |
| T2 | tzOffsetMinutes wrong in both writers (0 from client, 60 from Terra for UTC+3 user) | BOTH | _calculate_tz_offset upstream data quality + client s.tz.inMinutes=0 | ⚠️ confirmed |
| T3 | One-time migration script to clean existing duplicate sleep docs | BE script | new tools/ script after T1+T2 deploy | scheduled follow-up |
| Labs visibility gap | ||||
| L1 | Layer 1+2: key by analyteKey, render with labDisplayName + per-lab TrendType.benchmarkType | FE-hybrid + basiscore | service_daymemory.dart:1440-1492 + widget_trends_today.dart + InsightToday model | ⚠️ confirmed scope |
| L2 | Layer 3a: add dedicated InsightType entries + benchmarks for ~50-100 common labs | basiscore | basiscore/lib/src/models/summary/insight_type.dart | follow-up |
| Shop test order flow | ||||
| S1 | New flow for test ordering in the shop | TBD | awaiting spec | blocked on spec |
9. Where to look next (next reads, before any fix)
To upgrade the 🔍 items in §6 and §8:
_validate_package_limits— read._validate_trial_eligibility— read.validate_booking_time_constraintscall sites — grep.update_event_details— read end-to-end to map reschedule re-checks.- Clinician slot generation — lines 5780–5900 of
get_available_service_times. - Group service render in basishybrid — find the widget that renders a
group slot card and check whether it uses
slot.coachesor justslot.$3. - basishybrid
available: true/falseinterpretation — confirm what the backend stamps for direct-bookable individual slots.
10. How to use this doc
- Before implementing a fix in this area, re-read the relevant §6 row to know every place the rule is enforced.
- After implementing, update the relevant §6 row + §8 if the fix closes a gap.
- The 🔍 items are an explicit TODO list for upgrading our understanding — not all need to be done before any single fix, but the ones touching the fix's surface area MUST be promoted to ✅ first.
- §11 below logs significant simplifications / changes to the architecture itself. Per-bug fix notes still live in PR/commit notes.
11. Change log — architectural simplifications
2026-06-08 — Cross-booking display: single source of truth
Before: The basishybrid schedule grid called TWO endpoints sequentially to render cross-booking-blocked tiles:
getCrossBookingBlockingRules→ populates_crossBookingBlockedStartsgetAvailableServiceTimes→ returns the slot list (withblockedByCrossBookingflag dropped by basiscoreui's parser)
The grid filter at schedule.dart:1442 then keyed on
_crossBookingBlockedStarts.containsKey(slot.start) to decide whether to
KEEP a blocked slot (show as disabled tile) or DROP it.
When call #1 was slow / failed / returned empty (race, network, backend
hiccup), _crossBookingBlockedStarts was empty when slot rendering ran.
Slots came back from call #2 with available=false AND
isCrossBookingBlocked=false (per the empty map) → were filtered out
entirely. Users on at-quota days saw NO slots instead of disabled tiles
with the "X of Y today" badge, looking like the app was broken.
After: getAvailableServiceTimes in basiscoreui now parses
blockedByCrossBooking per slot AND populates two service-instance fields
(lastBlockedCrossBookingStarts, lastBlockedCrossBookingRules) as a
side effect of the SAME response. The grid reads them after the slot
listing future completes. No second endpoint call, no race.
Files touched:
basiscore/lib/src/services/service_clinic_storage.dart— new abstract getterslastBlockedCrossBookingStarts/lastBlockedCrossBookingRules.basiscoreui/lib/src/services/service_clinic_storage.dart— concrete impl populates them insidegetAvailableServiceTimes.basishybrid/lib/view/routes/schedule/schedule.dart— removed the separategetCrossBookingBlockingRulescall from the grid load path;_crossBookingBlockedStartsis now derived from the side-effect fields.
getCrossBookingBlockingRules and getCrossBookingBlockedSlotStarts
are kept for backward compatibility (other callers may still rely on
the explicit method). The grid no longer uses them.
2026-06-08 — Membership Sessions + cross-booking shown together
schedule.dart:_buildMembershipStatusWidget previously gated the
freeBookings benefit display behind if (items.isEmpty). When
cross-booking added an item, the per-membership monthly quota display was
silently dropped. Both signals are now added to the items list
unconditionally so users see e.g.:
🔁 Bookings — 1 of 1 remaining for this service type on Wed, Jun 24
🎟 Membership Sessions — 5 of 6 remaining this month
2026-06-08 — Confirm screen time displayed in clinic timezone
_formatConfirmDateTime previously used dt.hour / dt.weekday directly,
which returns values in the DEVICE timezone. The cross-booking message
just below it computed in CLINIC timezone. For users with phone-tz ≠
clinic-tz, the header date and the cross-booking date disagreed (e.g.
"Tue Jun 23 at 5 AM" in EEST header vs "Mon, Jun 22" in cross-booking
warning — same moment, different days). Now _formatConfirmDateTime
takes widget.location.timezone and formats consistently. Both lines now
show the clinic-local day, which is also the day the booking actually
exists in the calendar.
2026-06-08 — Cross-booking blocked message lists the blocking appointment + offers Reschedule
The blocked-status card on the confirm screen used to read just "Booking limit reached for this service type on Mon, May 25." With no indication of WHICH existing booking caused the block and no actionable next step, users treated this as a dead-end and complained.
Wired in TWO places so users get the same affordance no matter which surface they hit first:
(a) Confirm screen membership-status card. _CrossBookingStatus now
carries a blockingAppointments list (lightweight projection of the
events that consumed the user's quota — id, serviceId, serviceName,
startUtc, pre-formatted clinic-local displayWhen). Populated in
_getCrossBookingStatusForSlot from the same Firestore query that does
the count. The card surfaces:
- The first 2 blocking appointments inline ("Personal Training — Tue, Jun 23 at 5:30 PM"), with "+N more" if a clinic configured limit > 1
- A "Reschedule existing" primary button — fetches the BasisEventV1 by id
and opens the existing
showBasisModalReschedulemodal. On success, pops back to the live grid where the freed slot is bookable. - A "Continue" secondary button — dismisses the confirm screen so the user lands back on the grid to pick a different day.
(b) Grid tile tap dialog with direct swap. WidgetServiceInstance
now takes an optional onTapBlocked callback. When the user taps a
"Limit reached" tile, the parent handles everything:
- Resolves the specific blocking BasisEventV1 for the slot's clinic-day (matching any service in the rule, earliest if multiple).
- Renders a Cupertino dialog with both bookings shown by NAME + TIME: "You already have: • Personal Training — Mon, Jun 22 at 5:00 PM" "Move it to: • Wed, Jun 24 at 4:00 PM with Maria Ramirez"
- Actions: Continue (dismiss) + Reschedule to this one (direct
swap via
updateEventDetails— service / staff / start change in one backend call, no intermediate modal). - On success: grid refreshes, friendly "Booking moved" confirmation.
- On conflict ("slot was just taken"): friendly error mapping by code:
failed-precondition/aborted→ "That slot was just taken — please pick another time."permission-denied/ "cutoff" / "policy" → "This booking is past the reschedule window."- Anything else → generic "Reschedule failed."
Fallback to the full reschedule modal when direct swap is unsafe —
specifically when the destination slot is at a different LOCATION than
the existing booking (updateEventDetails doesn't expose location
change). The dialog then shows "Reschedule existing" instead, which
opens the multi-step modal so the user can pick the new location.
Day boundary computation uses calendar arithmetic
(tz.TZDateTime(loc, year, month, day + 1)) instead of
Duration(days: 1) so DST transition days produce correct windows.
This eliminates the dead-end UX AND collapses the 6-8 tap reschedule flow into a single confirmation for the common (same-location) case.
2026-06-09 — Lab values round-to-zero when sub-integer
UnitFormatter.labelFormatter in basiscore/lib/src/models/user/trend_type.dart
formatted lab unit values via value.encodeAsNumber(snap.numberUnit). That
helper defaults to precision: 0 (round to integer). For lab values like
CRP 0.4 mg/L the chart rendered "0 mg/L" — losing the only meaningful
information on the page.
Fix: changed all lab-relevant UnitFormatter cases to pass precision: 1.
encodeAsNumber's auto-strip means integer values still render clean
("56 mg/dL" not "56.0 mg/dL") while sub-integer values get their decimal
("0.4 mg/L"). Y-axis tick labels stay clean for the same reason.
Lab units changed: milligramsPerDeciliter, gramsPerDeciliter, microgramsPerDeciliter, nanogramsPerDeciliter, milligramsPerLiter, microgramsPerLiter, micromolesPerLiter, milliequivalentsPerLiter, nanomolesPerLiter, kilounitsPerLiter, gramsPerLiter, unitsPerLiter, unitsPerMilliliter, milliunitsPerLiter, milliunitsPerMilliliter, microUnitsPerMilliliter, nanogramsPerMilliliiter (sic), nanogramsPerMilliliter, picogramsPerMilliliter, cellsPerMilliliter, cellsPerMicroliter, countPerLiter, millionsPerMicroliter, thousandsPerMicroliter, millilitersPerMinutePer1_73m2, millilitersPerKilogramPerMinute, litersPerMinute, liter, femtoliters, picograms, milligramsPerGram. Already-decimal cases (ratio precision=2, ph precision=1) left as-is. Integer-only metrics (bpm, steps, calories, years, score, basePairs, breathsPerMinute) left at precision=0.
2026-06-20 — NEXT: Lever #2 conflict-check rewrite (slot latency)
Status: scoped + ready to implement. Server-only, no IPA dependency. Independent of soft hold work; can land in parallel.
Bottleneck. get_available_service_times in basis-functions/functions/src/functions_clinic.py runs nested linear scans inside the per-candidate-slot loop. For xdoz at peak (9 coaches × ~500 candidate slots × ~1000 events): ~4.5M comparisons per request. p95 = 3.4s, max = 7.6s in production. Zero 5xx — function isn't failing, it's just slow. Client perceives "no slots" and reloads while the response is still in flight.
Fix. Sort the data once per request, then binary-search (bisect) per slot. Three pieces:
_coach_conflicts(uid, s, e)(~line 5896): sort the coach's busy intervals once, build a prefix-max-end array, bisect for "intervals starting beforee" then check ifprefix_max_end > s. O(N) → O(log N) per call._coach_unavailable(uid, s, e)(~line 5902): same pattern, separate list.- Inline
for appt in scheduled_appointmentsloops at lines 5945 / 5989 / 6018: build a per-service sorted index once per request, bisect per slot to find the small candidate window. Most slots have 1-3 matching events instead of scanning all 1000.
Net: ~4.5M ops → ~45K ops per request. ~100× CPU reduction on the conflict-check stage. Expected wall-clock saving: 500-1500ms on big clinics, zero impact on small ones.
Why low risk.
- Functional equivalence is provable: bisect on sorted data returns the same answer as linear scan, by definition. Diff test verifies on real production data.
- No contract change. Same inputs → same outputs. Just faster.
- No data model change. No new collections, no fields, no migration. Pure in-memory rearrangement.
- Known gotchas enumerated (see [[slot-conflict-check-audit]] memory): datetime tz handling (existing code uses raw
appt.start/appt.end— must keep same form; Python raises on mixed aware/naive comparison which fails LOUD not silent), empty interval lists (guard withif not intervals: return False), overlapping intervals (prefix-max-end handles correctly), don't touch the group-service skip at lines 5860-5868 (built during coach_to_busy build, not per-slot). - Reversible in seconds via Cloud Run revision rollback:
gcloud run services update-traffic clinic-service --to-revisions=<prior>=100 --region us-central1 --project basis-hybrid. - Failure mode is "bug found, instant rollback, fix and retry." Not "silent data corruption" or "lost bookings."
Diff-test plan (mandatory before deploy). Pattern matches tools/slot_diff_opt12.py from the prior parallel-I/O deploy. Create tools/slot_diff_conflict_check.py:
- Fetch real xdoz data (clinic + clinicians + scheduled events) ONCE.
- Build candidate slots from coach availability blocks (reuse logic from
slot_diff_v5.js). - Run BOTH conflict-check algorithms (old linear, new bisect) on the same inputs.
- Diff
filtered_slotsoutput byte-for-byte across:- PT @ Playa (large workload, every code path)
- Virtual PT @ Playa (different service impl)
- A group service (exercises participant counter at 5945)
- With and without
include_cross_booking_blocked - With and without staff_unavailable events in window
- Synthetic edges:
- Back-to-back appointments (exactly touching at boundary)
- Fully-booked coach (every minute taken)
- Multi-day staff-unavailable block (the case that broke Tier 3a)
- Zero-duration events
- Assert zero divergence before deploying.
Rollback plan. Preserve old code as """...""" docstring next to new (established pattern per [[feedback_git_is_behind]] memory — git is unreliable for code archeology, so the file itself carries the prior implementation as a comment). Current serving revision before deploy = rollback target via traffic split.
Realistic effort. ~15 min for the refactor + diff test, plus 3-5 min cloud function deploy. ~20 min end-to-end including verification. NOT a day. Past estimates in this audit (and others) were anchored to "what a human dev would take" — recalibrate for actual mechanical work.
Expected end state. p95 drops from 3.4s → ~1.5-2s on big clinics. Smaller clinics unchanged. No data shape changes — basishybrid + basisflow-web see exactly the same slot list, just faster.
Composition with soft hold. Independent code paths (slot loop conflict check vs upfront hold gating). Both could land in parallel without merge conflict. Order doesn't matter.
2026-06-20 — DEFERRED to next build: Soft hold (per-clinic opt-in slot reservation)
Status: scoped + decisions ratified + risk-assessed + deferred. Reason: book_appointment is payment-adjacent (per [[feedback_payments_safety]]) and group-service hold semantics are non-trivial. The conservative staged path below should make a clean execution next build. Updated 2026-06-20 with this session's design + risk learnings.
Session-end design decisions (locked in this session, override only with reason):
- Duration: 60s default, slider {30, 60, 90, 120, 180, 300} — per-clinic, not global. Field at
clinic.clientApp.softHoldDurationSec. - Toggle:
clinic.clientApp.softHoldEnabled(mirror ofsameDayOnlylocation — verify before write). - Hold doc ID: deterministic hash
sha1(start|end|locationId|coachId|serviceId), not random UUID. Three reasons: (1) singletransaction.get(holds.doc(hash))instead of.where().where().where()query inside tx — fewer reads, no index requirements; (2) natural dedup if same user retriescreate_holdfor same slot; (3) staff override lookup is one-liner. - Staff respect holds (REVISED from earlier "exempt"): staff bookings honor active holds with an inline override prompt — "Slot is currently held by [client name]. Override and book?" → Cancel / Override. Default-safe with escape hatch. Audit-logged (
transactions/{id}withtype=staff_hold_override, holder + overrider UIDs). - Group services DEFERRED to v2: v1 is individual-only. If
softHoldEnabled=trueand serviceisGroupService, soft hold path skips and behavior = today. Reasoning: per-seat counting + capacity + concurrent seat holds = real complexity that could double-book a class if wrong. Individual covers the contended 1:1 use case. - Expiry UX: no "Re-hold" button. Timer hits 0 → "Hold expired" badge + "Pick another time" → pops back to slot grid. Re-tapping the slot from the grid is the same action as the original first tap (one mechanism, less state).
- Cleanup function: every 60s schedule, GB_1, 120s timeout backstop. Collection-group query on
holds.
What it is. When clinic enables it, the act of starting a booking attempt reserves the slot for X seconds against everyone else. Prevents two clients racing for the same slot during payment / confirmation flow.
Pre-decided (locked in earlier this session).
- Per-clinic toggle in basisflow-web Settings > Client App
- Default OFF (opt-in only — zero behavior change for existing clinics)
- Default duration 60s, clinic-configurable 15–180s
- Staff bookings exempt (staff have wider mental model + authority)
- Group services: holds count seats against capacity
- Waitlist actions exempt (joining waitlist isn't a slot grab)
- Reschedule: hold the new destination slot (race risk same as new booking)
Implementation pieces.
| Layer | What |
|---|---|
| Firestore | New clinicsv2/{clinic}/holds/{holdId} subcollection. Composite index (start, end, locationId) for slot-listing read. Rules: hold owner can read/write own, others read-only. |
clinic_service handlers | Three new endpoints: create_hold (tx: check no overlapping active holds + no event conflict → write), confirm_hold (tx: validate not expired + caller owns → create event + delete hold), release_hold (idempotent delete) |
get_available_service_times | Read holds in parallel with events (when softHoldEnabled). Slots overlapping an active hold by a DIFFERENT user → treat like conflict. Same-user holds visible to that user only (so they can see "your hold, 47s left"). |
book_appointment | Honor holds at confirm — if slot has an active hold owned by someone else, reject with aborted. |
| Scheduled cleanup function | cleanup_expired_holds every 60s — delete docs where expiresAt < now - 60s (small grace). Cheap collection-group query. |
| basishybrid book flow | Call create_hold on slot-tap, show countdown timer using expiresAt, confirm_hold on confirm, release_hold on back/cancel, handle expiry mid-flow with toast "Slot expired, please retry" |
| basisweb book flow | Same as basishybrid |
| basisflow-web Settings > Client App | Add softHoldEnabled toggle + softHoldDurationSec picker. Hard-block save if duration outside 15-180s range. |
Concurrency edge cases (the actual difficulty).
- Two users tapping same slot 50ms apart → tx ensures one wins, loser gets
abortedwith retry suggestion - Hold expires 200ms before confirm fires →
confirm_holdchecksexpiresAt > nowand rejects withfailed-precondition - Network drops mid-confirm → user re-opens app, hold may or may not still be live; client should re-check via slot listing
- App backgrounded during hold window → countdown desync on resume; clamp to
max(0, expiresAt - now)on resume - Group capacity: held seat overlaps with live booking → slot listing for group sums
participants + active_other_holds < capacity - Two clients hold same group seat → tx race, one wins
Mitigations come from Firestore transactions in all three handlers + the 60s cleanup function for stale holds + slot listing reading holds in parallel with events (so the snapshot is consistent within a request).
Failure mode if buggy. Stale holds blocking slots → users see "slot taken" when it's not. Bad UX but recoverable on the next cleanup tick (60s max). Doesn't corrupt bookings or lose data.
Default-OFF safety net. Until a clinic admin flips the toggle in Settings > Client App, NO clinics see any behavior change. The new code paths are gated entirely on clinic.softHoldEnabled == true. Strict opt-in like weekly slot release.
Realistic effort. Server changes ~30 min editing + ~10 min deploy. Client changes ~30 min each app. Settings UI ~10 min. Verification + manual concurrency tests ~30 min. Total ~2 hours editing + cloud deploys. Not days.
IPA dependency. basishybrid client work ships in the next IPA (1.7.66 or later). basisweb deploys independently. Server can deploy first — dormant until a clinic enables.
Verified code touchpoints (read this session — line numbers may drift, but the patterns are stable):
REQUEST_MODEL_MAPat functions_clinic.py:13683 — add 3 entries(handler, RequestModel, [], 'all')likeclient_reschedule_appointmenttemplate.book_appointmentouter at line 7547;book_appointment_transactioninner at line 7565 (@firestore.transactional).- Tx event-write insertion point at line 8041 — add
transaction.get(hold_ref)+ conditionaltransaction.delete(hold_ref)BEFORE this line. - Existing override pattern (
admin_override,has_override_permission) at lines 7550, 7636 — mirror forstaffHoldOverride. - BookingDrawer.tsx slot-tap callback at ~line 760,
handleSubmitat line 782,book_appointmentrequest build at line 810. - Second basisflow-web booking surface:
app/(main)/calendar/page.tsx:1811(no slot grid → error-driven prompt).
Staged execution path (9 steps, default-OFF gate at every layer means each step is independently deployable + reversible):
- Schema (Pydantic + Firestore rules + indexes) — no behavior change
- Three new handlers (gated; return early when flag off)
book_appointmenthold check (gated; diff test flag-off byte-equivalent)- Slot listing reads holds (gated)
cleanup_expired_holdsscheduled function- basishybrid client work (countdown timer + create/confirm/release wiring)
- Settings UI in basisflow-web (the toggle that flips it on)
- End-to-end verify on test clinic ONLY
- Staff override prompt (basisflow-web BookingDrawer + calendar) — last piece per user instruction
Risk profile (what we caught this session):
book_appointmentis payment-adjacent → CLAUDE.md + [[feedback_payments_safety]] require pre-edit confirmation. Default-OFF gate is the mitigation.- Group services genuinely complex — defer to v2.
- Tx retry under load could amplify if hold doc has many concurrent writers; default-OFF means no traffic until intentionally enabled on a test clinic first.
- Stale-hold failure mode is recoverable (60s cleanup), NOT data-corrupting.
Open questions for next-build session (none are blockers):
- Audit log shape for staff override — re-use
transactions/{id}with newtypeenum, or separate collection? - Group v2 design — needs seat-aware semantics; not started.
2026-06-09 — Labs Metrics tab: bypass InsightType preference filter
applyBiomarkerPreferenceFilter (widget_trends_today.dart) gates every
item by preferenceMap.containsKey(e.insightType). That's correct for
biomarkers (HR, sleep, etc. — InsightType is a meaningful allowlist) but
WRONG for labs. Most of the ~600 BasisLabType entries collapse onto
InsightType.cognitiveFunctionTest, so unless that InsightType is in
the user's preferences (it isn't by default), every lab with the
collapsed InsightType gets dropped. Result: user with 53 distinct
canonical analytes saw only 20 cards in the Metrics > Labs tab.
Fix: at all three call sites of applyBiomarkerPreferenceFilter in
widget_trends_today.dart, when the rendering pass is for labs
(identical(widget.fetch, WidgetProgress.fetchFromLabs)), pass
const <InsightType>[] for preferences. Empty preferences shortcircuits
the if (preferences.isNotEmpty) block inside the filter, so all labs
survive while the other filters (search, status, NA hiding) remain.
Result: Metrics > Labs card count = distinct canonical analytes (53 for the test user). Trends detail accessible for every imported lab.
2026-06-08 — Date picker dropdown decoupled from filter state
_buildDateChoices was sourced from _availableSessions — the
already-filtered slot list. When "Only Available" hid all slots for a
day, that day was missing from the date picker too, so users couldn't
even SELECT a day to look at its slots. Now the dropdown is built from
the user's max booking horizon (next 21-30 days), with " (no
availability)" suffix on days that currently have no slots. The dropdown
no longer collapses based on filter state.