7.0 KiB
2026-05-02 — Booking Lifecycle (Phase C)
Companion to
BuildLog.md. Predecessor:2026-05-02-therapist-self-serve.md. Closes Phase C of the post-payments UX plan.
Milestone
The booking lifecycle has all the verbs the practice needs: create, view, reschedule, mark complete, mark no-show, cancel — for both customer and admin. Reschedule is atomic (cancel-old + create-new in one transaction; rollback on conflict so the customer never loses their old slot to a phantom new booking). Single "rescheduled" email replaces the cancel + new-confirmation pair. Booking detail pages exist for both customer and admin.
What landed
| Path | Role |
|---|---|
src/lib/booking.ts |
+rescheduleBooking() (atomic, transactional, surfaces BookingConflictError if new slot collides — old booking stays intact). +markComplete() and +markNoShow() (CONFIRMED→target only, idempotent). |
src/lib/email.ts |
+sendBookingRescheduled() template (one email summarizing old → new). |
src/app/admin/bookings/[id]/page.tsx |
Detail page — all booking fields incl. cancel-audit, payment-status placeholder, room-released-at, action buttons (Mark complete / Mark no-show / Cancel). Mark-* buttons only appear on past CONFIRMED bookings. |
src/app/account/bookings/[id]/page.tsx |
Customer detail page — service / when / therapist / room / price + Reschedule + Cancel. Owner-or-admin authorization on the action. |
src/app/book/page.tsx |
Accepts fromBookingId param — shows "Rescheduling your X — pick a new time" banner; threads param through to slot links and the date picker form. |
src/app/book/confirm/page.tsx |
Detects fromBookingId and routes through rescheduleBooking + sendBookingRescheduled instead of createHold + sendBookingConfirmation. Page heading and copy adapt. Conflict redirects back to /book with taken=1 and the fromBookingId preserved. |
src/app/account/bookings/page.tsx |
Per-row Reschedule link → /book?serviceId=…&fromBookingId=…. Booking row is now itself a link to the detail page. |
src/app/admin/bookings/page.tsx |
Booking row links to detail page. Therapist-name fallback for null. |
test/booking.test.ts |
+8 tests: reschedule happy path, atomic rollback on conflict, rejects rescheduling CANCELLED, can change therapist/room on reschedule, markComplete CONFIRMED→COMPLETED, markComplete idempotent, markComplete rejects HOLD, markNoShow CONFIRMED→NO_SHOW. |
What's verified
pnpm test— 79/79 green (was 71; +8 new in this session)pnpm lint— cleanpnpm exec tsc --noEmit— clean- Live smoke test (curl + Mailpit, dev server):
- Customer (alex@example.com): signed in via magic link →
/account/bookingsshows Reschedule + Cancel buttons → click Reschedule →/book?fromBookingId=…shows "Rescheduling your..." banner and slot links carryfromBookingId→ click new slot →/book/confirmshows "Confirm reschedule" heading, "previous booking will be cancelled" copy, hiddenfromBookingIdfield - Admin (admin@touchbase.local):
/admin/bookings/{id}renders full detail with customer name, therapist link, room link, all fields, Cancel button (Mark complete / no-show buttons hidden because the booking is in the future — they appear only after the booking end time)
- Customer (alex@example.com): signed in via magic link →
Decisions ratified
| Decision | Resolution |
|---|---|
| Reschedule semantics | Atomic — cancel-old + create-new in one db.$transaction. If the new insert fails (conflict, FK, etc.), the entire transaction rolls back; old booking stays intact. Reason: customer should never lose their slot to a phantom new booking. |
| Reschedule email | One rescheduled email, not cancel + new-confirmation pair. Reason: cleaner customer experience; one notification with old-vs-new framing. |
| Mark complete / no-show timing | Buttons only render when status === "CONFIRMED" && booking.endsAt < now. Reason: marking a future booking complete is meaningless and almost always a misclick. |
| Mark complete / no-show idempotency | Re-marking returns { ok: true } (no-op). Switching from one final state to another is rejected. Reason: prevents accidental status flips after the fact. |
| Cancel reason on reschedule | "rescheduled" literal in Booking.cancelReason. Reason: distinguishable from customer/admin cancellation in audit; could drive metrics later. |
| Authorization on customer detail page | Owner or admin can view; otherwise redirect to /account/bookings. Same on cancel action. |
| Authorization on admin detail page | Layout already requires role=ADMIN; actions re-check on the safe side. |
/account/bookings/[id] reachable by admins |
Yes — they may want to debug from a customer's POV. The cancel button still works. |
| Reschedule preserves service by default | rescheduleBooking defaults newServiceId/therapist/room to the old booking's. The customer flow always passes the same serviceId; admin could pass different ones if/when we build admin-side reschedule. |
| Mark-* helpers return discriminated unions | { ok: true } or { ok: false; reason: string }. Reason: same shape as cancelBooking so the UI can react without exceptions. |
Gotchas hit
None — clean session.
Open questions
- Customer-visible brand name (still pending)
- Currency
- Stripe account ownership
- NEW: should reschedule require explicit confirmation if the old booking was CONFIRMED with a deposit? Not relevant until 5d Stripe.
- NEW: admin "reschedule on customer's behalf" UI flow — currently admins cancel and the customer rebooks. We have the
rescheduleBookinghelper; the admin UI for it is a future enhancement.
Roadmap status
UX-completeness:
- A — Admin CRUD: done 2026-05-02
- B — Therapist self-serve: done 2026-05-02
- C — Booking lifecycle UX: done 2026-05-02 (this session)
- D — PWA shell (per
BuildLog.md§11) - E — Polish — 404, empty states, mobile review, dark-mode glance
Recommended next step
Phase D — PWA shell. Per BuildLog.md §11, this is the "Stage 1" mobile story we promised at bootstrap:
manifest.webmanifestwith name, icons,display: "standalone", theme color, start URL- A minimal service worker (
next-pwaor hand-rolled with Workbox) — network-first for HTML, stale-while-revalidate for static assets, no caching of API/booking endpoints - iOS Safari quirks: separate
apple-touch-icon,apple-mobile-web-app-capable, status bar style - Test on a phone or in DevTools "Add to Home Screen"
~half-day to a full day. After D, Phase E (~half-day): 404 page, empty-state polish, mobile-first audit, dark-mode glance.
How to resume
cd /Users/noise/Documents/code/touchbase
docker-compose up -d postgres mailpit
pnpm db:seed
pnpm tsx scripts/book-on-behalf.ts alex@example.com "60-minute Swedish" 2026-05-05T10:00
pnpm dev
# As alex: sign in → /account/bookings → click row to see detail → Reschedule → pick new time → confirm
# As admin: sign in → /admin/bookings → click row → view detail with action buttons