Skip to content

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, bookingTypeStep props are the symptoms).
  • Data layer predates TanStack Query. useAvailableDates is raw useState fetch, instantiated once per month (4 instances in HourlyBookingScheduler), zero caching — every mount refetches 2–4 months of availability.
  • Flow containers render everything twice. Both HourlyBookingContent (1,648 lines) and TourSchedulerModal (837 lines) duplicate their entire flow as sibling lg:hidden / hidden lg:grid trees — 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 + useDaySlots on TanStack Query (old hooks untouched until consumers move)
  • [x] MonthCalendar molecule — custom grid on @internationalized/date, keyboard nav + aria
  • [x] SlotList molecule — new markup, decoration rules ported from TimeSelector logic

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/scheduler page 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] /book route: BookingFlow host with URL-synced context/slot state, noindex metadata
  • [x] Step engine + location picker step + booking-type step (hourly card links to /hourly until Phase 3)
  • [x] Tour branch: slot → details → submit → success, single responsive tree, Scheduler inside
  • [x] Convert tour entry points: TourModalContext navigates (4 callers converted for free), TourRequestForm + ResourceDetailClient link 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: postTourBooking sessionStorage key was write-only — deleted with the handoff write in useTourBooking

Phase 3 — Hourly branch + LP recomposition

  • [x] Extract useHourlyResource, usePurchasableCreditPackages (renamed: staff CRUD useCreditPackages already exists), useHourlyCheckout (outcome seam), hourly-pricing lib
  • [x] Hourly branch on /book: gear-grouped studio picker → duration switcher in meta → Scheduler with cart in the meta pane → payment step (wide card narrows; narrow gets sticky cart bar + payment dialog)
  • [x] Convert hourly entry points: ResourceDetailClient Book 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: trackMmsModalOpened now 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] BookingShell organism: 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/date URL sync (replace) + slot push semantics, both branches
  • [x] BookingTypeStep hourly card enters the hourly branch on /book (stale /hourly link)
  • [x] /book?type=hourly&resource=<id> works without location (context derived from resource)
  • [x] Simplified LP mode via ?minimal=1 forwarded to checkout
  • [x] Terminal not-bookable state when a calendar has no appointment types (was: infinite skeleton)
  • [x] Batch/multi-session removal: isBatchMode branches, cart props, reservation_ids handling (check staff consumers of API items[] before touching backend)
  • [x] Guest hourly checkout API: auth-optional session initialize, stub-by-email via resolveStubUserFromStaff seam
  • [x] Hardening: claimed-account guard (409 → sign-in prompt), per-IP rate limit on public comp/guest endpoints, per-offer global redemption cap, requiresVerifiedPhone offer flag (default false)
  • [x] Frontend guest checkout wiring; auth-modal fallback deleted

Phase 4 — Cleanup

  • [x] Delete TourSchedulerModal, HourlyBookingModal, HourlyBookingWithPicker; TourModalContext kept as a nav helper
  • [x] Delete SchedulerAccordion, HourlyBookingScheduler, TourScheduler, CoreScheduler, BadgeScheduler, DateSelector, TimeSelector, UserInfoForm, BookingConfirmation, CartSummary, AppointmentTypeSelector, deprecated TourModal, CmHelpFooter + their tests
  • [x] Delete old useAvailableDates/useTimeSlots; postTourBooking write-only handoff removed
  • [x] Update docs/engineering/architecture/frontend/components.md + public-pages.md (booking + tour modal sections); demo/tour-scheduler deleted
  • [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.
  • HourlyBookingContent extraction 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: true wrapper (~50 lines) over #843's presentational PhoneOtpSteps, wired to the requiresVerifiedPhone offer 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 is initialize-hourly's schema, HourlyCheckoutService, and the batch webhook handling in PaymentIntentHandler — 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).
  • ~~openTourModal is a misnomer~~ Done 2026-07-12: TourModalContext dissolved into tourBookingPath() (lib/booking-links.ts); call sites push it via the top-loader router, provider removed.
  • trackMmsModalOpened stale name: fires on navigation now, kept for series continuity (Phase 3 open note above).

Decided during execution (formerly open)

  • Route name: /book shipped as-is.
  • Narrow details/confirm presentation: dialog over the mounted picker (cal.com mobile pattern), with summary chips.
  • /book SEO posture: noindex, pure app surface.

Non-issues (verified)

  • mode='reschedule' in SchedulerAccordion is dead — no wrapper passes mode. The member reschedule flow (unmerged worktree-reschedule-hourly-bookings branch) uses its own MemberRescheduleModal with plain inputs and touches none of these components (one deleted line in HourlyBookingContent), so merge-conflict risk with that branch is negligible.