# 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` — clean - `pnpm exec tsc --noEmit` — clean - Live smoke test (curl + Mailpit, dev server): - **Customer (alex@example.com)**: signed in via magic link → `/account/bookings` shows Reschedule + Cancel buttons → click Reschedule → `/book?fromBookingId=…` shows "Rescheduling your..." banner and slot links carry `fromBookingId` → click new slot → `/book/confirm` shows "Confirm reschedule" heading, "previous booking will be cancelled" copy, hidden `fromBookingId` field - **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) ## 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 1. Customer-visible brand name (still pending) 2. Currency 3. Stripe account ownership 4. **NEW**: should reschedule require explicit confirmation if the old booking was CONFIRMED with a deposit? Not relevant until 5d Stripe. 5. **NEW**: admin "reschedule on customer's behalf" UI flow — currently admins cancel and the customer rebooks. We have the `rescheduleBooking` helper; 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.webmanifest` with name, icons, `display: "standalone"`, theme color, start URL - A minimal service worker (`next-pwa` or 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 ```bash 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 ```