Scheduler Refactor — cal.com-style Slot Picker + Flow Restructure
Status: scope locked 2026-07-09, Phase 0 in progress. Design intent is canonical in spec.md — this plan is execution only (inventory, phases, risks).
Motivation
The customer-facing scheduler is built around a HeroUI Accordion (SchedulerAccordion.tsx) that fights every surface it's embedded in:
- Accordion wastes space. One panel visible at a time, collapsed step headers eat vertical room, and desktop width goes unused. cal.com solves the same problem with a calendar+slots side-by-side layout.
- Nested scrolling. Modal body scrolls → accordion content scrolls (
overflow-y-auto) → slot list scrolls (ScrollShadow max-h-[322px]). On mobile this is three stacked scroll traps. - Fixed pixel heights (
min-h-[320px]calendar,max-h-[322px]slots) don't adapt to context — LP section, sheet modal, and card all get the same rigid internals. - Step logic is entangled with an animation component. Progression is driven by accordion selection keys (
manualStep+ magic keys'2'/'3'/'4'/'4b'/'5'), making it brittle to restyle or re-order. - The picker owns too much. User info, confirmation summary, and booking-type selection live inside it, so every consumer inherits a five-step machine (
skipUserInfo,hideBookingDetails,bookingTypeStepprops are the symptoms). - Data layer predates TanStack Query.
useAvailableDatesis rawuseStatefetch, instantiated once per month (4 instances inHourlyBookingScheduler), zero caching — every mount refetches 2–4 months of availability. - Flow containers render everything twice. Both
HourlyBookingContent(1,648 lines) andTourSchedulerModal(837 lines) duplicate their entire flow as siblinglg:hidden/hidden lg:gridtrees — every change is made twice. - Booking is trapped in modals. Tours and hourly booking live in sheet modals stacked over marketing pages — not shareable, not SEO-visible, awkward on mobile (modal-in-modal pressure for auth and confirm steps), and post-auth resume requires sessionStorage replay hacks instead of a plain URL. cal.com's model is a page.
Current inventory
| File | Lines | Role | Fate |
|---|---|---|---|
organisms/scheduling/SchedulerAccordion.tsx |
463 | Accordion step machine | delete |
organisms/scheduling/HourlyBookingScheduler.tsx |
166 | 4-month fetch wrapper + type step | delete |
organisms/scheduling/TourScheduler.tsx |
84 | 2-month fetch wrapper | delete |
organisms/scheduling/CoreScheduler.tsx |
69 | unused | delete |
organisms/scheduling/BadgeScheduler.tsx |
143 | unused | delete |
molecules/scheduling/DateSelector.tsx |
78 | HeroUI Calendar wrapper | delete — MonthCalendar built fresh |
molecules/scheduling/TimeSelector.tsx |
155 | Slot Listbox | delete — SlotList built fresh (decoration rules ported) |
molecules/scheduling/UserInfoForm.tsx |
— | contact form | delete — details step rebuilt fresh |
molecules/scheduling/BookingConfirmation.tsx |
— | summary panel | delete — no separate summary screen in new UX (meta pane is the running summary) |
molecules/scheduling/CartSummary.tsx |
— | multi-slot cart | delete — cart panel rebuilt fresh |
hooks/scheduling/useAvailableDates.ts |
92 | per-month raw fetch | replace with TanStack Query |
hooks/scheduling/useTimeSlots.ts |
65 | imperative per-date fetch | replace with TanStack Query |
organisms/booking/HourlyBookingContent.tsx |
1,648 | hourly flow (cart/payment/checkout, duplicated mobile+desktop trees) | restructure onto /book |
organisms/modals/TourSchedulerModal.tsx |
837 | tour flow (lead capture, duplicated trees) | delete — flow moves to /book |
organisms/booking/HourlyBookingWithPicker.tsx |
362 | location/studio picker steps | dissolve — pickers become /book steps |
organisms/modals/HourlyBookingModal.tsx |
101 | sheet shell | delete |
contexts/TourModalContext.tsx |
— | global tour modal opener | replace with navigation to /book |
atoms/forms/PaymentMethodSelector.tsx |
— | balance/money chooser + pricing summary | new presentation, pricing logic ported |
Entry points to convert (booking modals → links/navigation to the booking page): ResourceDetailClient (both hourly and tour CTAs), TourRequestForm, LocationActionButtons, TourCallout, LocationDetailHeader, LocationLandingContent, LP sections (lp/[marketSlug], lp/mms2026, lp/cherry-city-first-practice). Staff's CreateTourModal is a separate flow and is untouched. ModalWrapper/AuthModal (auth) stays — it's an auth dialog, not a booking surface.
Target architecture
Canonical in spec.md: the /book on-page surface (query-param context, URL-synced slot state, BookingFlow component with the page as one host), the Scheduler organism (pure slot picking, container-query layout, verified cal.com field notes, height/layout-shift policy), TanStack Query data layer, tour + hourly flow branches, the checkout-outcome seam, and the behavior-preservation contract.
Phases
Each phase is a PR; funnel-critical surfaces swap one at a time.
Phase 0 — Foundations
- [x]
useAvailability+useDaySlotson TanStack Query (old hooks untouched until consumers move) - [x]
MonthCalendarmolecule — custom grid on@internationalized/date, keyboard nav + aria - [x]
SlotListmolecule — new markup, decoration rules ported fromTimeSelectorlogic
Phase 1 — Scheduler organism
- [x] Container-query responsive picker with meta pane slot (duration switcher renders inside
meta, provided by flows in Phases 2-3) - [x]
/demo/schedulerpage rendering it at 1100px / 640px / 380px for visual iteration (Playwright-verified) - [x] Component tests (selection, decorations, window clamp, conflict disable, auto-select, month auto-advance, empty state)
- [x] Visual design pass: closed — Aaron signed off on the shipped look (2026-07-11), no separate pass needed
Phase 2 — /book page + tour branch
- [x]
/bookroute:BookingFlowhost with URL-synced context/slot state, noindex metadata - [x] Step engine + location picker step + booking-type step (hourly card links to
/hourlyuntil Phase 3) - [x] Tour branch: slot → details → submit → success, single responsive tree,
Schedulerinside - [x] Convert tour entry points:
TourModalContextnavigates (4 callers converted for free),TourRequestForm+ResourceDetailClientlink out with sessionStorage prefill handoff - [x] Verified in browser vs local API: type/location steps, real availability, details step, back-preserves-date, deep link. NOT live-verified: actual POST /tours submission (mutation-tested only; avoids junk Acuity appointments)
- Resolved in Phase 4:
postTourBookingsessionStorage key was write-only — deleted with the handoff write inuseTourBooking
Phase 3 — Hourly branch + LP recomposition
- [x] Extract
useHourlyResource,usePurchasableCreditPackages(renamed: staff CRUDuseCreditPackagesalready exists),useHourlyCheckout(outcome seam),hourly-pricinglib - [x] Hourly branch on
/book: gear-grouped studio picker → duration switcher in meta →Schedulerwith cart in the meta pane → payment step (wide card narrows; narrow gets sticky cart bar + payment dialog) - [x] Convert hourly entry points:
ResourceDetailClientBook CTA, market LP fork card, mms2026 + cherry-city comp CTAs all navigate to/book - [x] LP recomposition resolved as navigation, not inline embeds: the LPs never had inline booking sections — all three hosted the flow in modals, so under the on-page direction their CTAs link to
/book(comp via?offer=<code>) and the modals are deleted. Spec updated accordingly. - [x] Comp mode on
/book: offer-scoped studio picker, booking-window clamp, hidden prices, 240-min duration cap, single-slot → details form → Book Free, AlreadyRedeemedModal, success panel - [x] Verified: browser end-to-end at 1440px vs local API (studio picker → durations → slots → 2-item cart with segmented peak math → payment defaults). NOT live-verified: checkout submission (Stripe session/auth), comp submission, dedicated room (needs a dedicated-org login)
- [x] Per-offer presentation config (
lib/comp-offers.ts): display name + collectArtistName per offer code — Cherry City comp keeps its no-artist-name form, MMS keeps requiring it, AlreadyRedeemedModal gets real offer names back - Open:
trackMmsModalOpenednow fires on navigation (stale name, continuous series)
Phase 3.5 — Hourly parity + guest checkout (spec-compliance round, scoped 2026-07-11)
Spec review found the hourly branch under-built relative to tour. Scope locked with Aaron:
- [x]
BookingShellorganism: shared card/meta/transition/dialog shell (container-query), consumed by both branches; hourly gains dialog summary chips + URL-synced slot/step state; kills the duplicated wide-card markup - [x]
month/dateURL sync (replace) +slotpush semantics, both branches - [x]
BookingTypeStephourly card enters the hourly branch on/book(stale/hourlylink) - [x]
/book?type=hourly&resource=<id>works withoutlocation(context derived from resource) - [x] Simplified LP mode via
?minimal=1forwarded to checkout - [x] Terminal not-bookable state when a calendar has no appointment types (was: infinite skeleton)
- [x] Batch/multi-session removal:
isBatchModebranches, cart props,reservation_idshandling (check staff consumers of APIitems[]before touching backend) - [x] Guest hourly checkout API: auth-optional session initialize, stub-by-email via
resolveStubUserFromStaffseam - [x] Hardening: claimed-account guard (409 → sign-in prompt), per-IP rate limit on public comp/guest endpoints, per-offer global redemption cap,
requiresVerifiedPhoneoffer flag (default false) - [x] Frontend guest checkout wiring; auth-modal fallback deleted
Phase 4 — Cleanup
- [x] Delete
TourSchedulerModal,HourlyBookingModal,HourlyBookingWithPicker;TourModalContextkept as a nav helper - [x] Delete
SchedulerAccordion,HourlyBookingScheduler,TourScheduler,CoreScheduler,BadgeScheduler,DateSelector,TimeSelector,UserInfoForm,BookingConfirmation,CartSummary,AppointmentTypeSelector, deprecatedTourModal,CmHelpFooter+ their tests - [x] Delete old
useAvailableDates/useTimeSlots;postTourBookingwrite-only handoff removed - [x] Update
docs/engineering/architecture/frontend/components.md+public-pages.md(booking + tour modal sections);demo/tour-schedulerdeleted - [x] Grep for stragglers importing deleted modules (typecheck + full web suite clean)
Risks
- Revenue funnel. Tours and hourly bookings are the two conversion paths. Mitigation: API contracts untouched, phase-per-PR, behavior checklist above verified per phase (Playwright against dev), old components deleted only after both flows are swapped.
- Modal → page changes the interaction, not just the container. A CTA that opened an overlay now navigates away from the marketing page. Mitigation: context fully prefilled in the link so the user lands one step from picking a time; LPs keep their inline flows so conversion-critical pages lose nothing. Watch tour-scheduled and hourly-booked rates across the swap (PostHog).
- HeroUI Calendar styling ceiling. Contained behind
MonthCalendar; custom grid is a molecule-local swap. HourlyBookingContentextraction churn. Checkout logic moves verbatim into hooks first (separately committable), layout restructure after — two reviewable steps, not one big-bang diff.
Deferred (post-PR #880)
- Guest phone-OTP verify step: lands after ADR-022 (#843) merges — a
shouldCreateUser: truewrapper (~50 lines) over #843's presentationalPhoneOtpSteps, wired to therequiresVerifiedPhoneoffer flag already enforced by the comp endpoint. Full design in spec.md's hardening section. - API
items[]path stays (Aaron 2026-07-11: leave it, may revisit): zero consumers (staff surfaces checked) but it is a working money path per ADR-019. If a removal reason ever appears, the blast radius isinitialize-hourly's schema,HourlyCheckoutService, and the batch webhook handling inPaymentIntentHandler— its own PR with deliberate verification. - ~~Offer hardening columns have no admin surface~~ Done 2026-07-12: Redemption Limits section on the admin offers form (per-user cap, total cap, duration cap, verified-phone gate) + FREE_BOOKING type option. Resource/location allowlists remain script-set (need pickers).
- ~~
openTourModalis a misnomer~~ Done 2026-07-12:TourModalContextdissolved intotourBookingPath()(lib/booking-links.ts); call sites push it via the top-loader router, provider removed. trackMmsModalOpenedstale name: fires on navigation now, kept for series continuity (Phase 3 open note above).
Decided during execution (formerly open)
- Route name:
/bookshipped as-is. - Narrow details/confirm presentation: dialog over the mounted picker (cal.com mobile pattern), with summary chips.
/bookSEO posture: noindex, pure app surface.
Non-issues (verified)
mode='reschedule'inSchedulerAccordionis dead — no wrapper passesmode. The member reschedule flow (unmergedworktree-reschedule-hourly-bookingsbranch) uses its ownMemberRescheduleModalwith plain inputs and touches none of these components (one deleted line inHourlyBookingContent), so merge-conflict risk with that branch is negligible.