Skip to content

Bookings Hub Architecture

Everything on this page derives from the pure logic in src/lib/components/bookings/board-utils.ts and the page wiring in src/routes/(app)/p/[slug]/bookings/+page.svelte.

The board model: lanes are derived, never stored

The booking_status column never encodes board position. laneForBooking() computes the lane at render time from three inputs: the booking status, the joined episode's status, and the clock. A confirmed booking flows automatically from Confirmed to Date Passed the moment its slot time passes (decision 2026-07-11): no cron, no status write, the function simply reads new Date().

Six lanes (LANES):

Lane idLabelContents
sentSentGuest Network invites for the podcast (NOT bookings); informational until the guest books
pendingPendingstatus = 'pending' awaiting host confirmation
confirmedConfirmedstatus = 'confirmed' with a future slot
date_passedDate PassedPast confirmed bookings (awaiting outcome) plus completed and no_show
publishedPublishedAny non-canceled booking whose linked episode has status = 'published'
canceledDeclined & Canceledstatus = 'canceled'; terminal, delete via the card menu

Derivation precedence in laneForBooking() matters: canceled is terminal regardless of the episode; a published episode promotes any still-live booking to Published; pending next; a confirmed booking splits on the clock; everything else (completed | no_show) is Date Passed.

isAwaitingOutcome() flags the Date Passed subset whose status is still confirmed (the host has not said what happened yet); the UI renders the amber "Awaiting outcome" badge (AWAITING_OUTCOME_BADGE in status-meta.ts). Published is likewise a derived presentation (PUBLISHED_META), not a booking_status.

Booking mode (#295 item 1)

isGeneralBooking(booking) reads booking.booking_sessions.creates_episode and returns TRUE only when it is explicitly false. Everything mode-aware goes through it: the card's "General booking" chip (where an episode title would otherwise render) and the confirm/cancel/delete dialog copy in BookingActionDialogs.svelte.

Never infer the mode from episode_id. bookings.episode_id is ON DELETE SET NULL, so a host deleting an episode leaves an EPISODE-mode booking with a null id; labelling that "General booking" would be wrong. A missing snapshot (a row serialized before the column existed) reads as episode mode, matching the column's DEFAULT TRUE.

Lane derivation is unchanged. General bookings flow pending → confirmed → date_passed and finish completed | no_show | canceled; Published is simply unreachable for them, because laneForBooking() reaches it only via booking.episodes?.status, which is always null here. The existing null-episode unit test in board-utils.test.ts already covered that path.

The status machine

The only mutations the backend supports:

text
pending   -> confirmed                  (confirm)
pending   -> canceled                   (decline)
confirmed -> canceled                   (cancel)
confirmed -> completed | no_show        (outcome, only after the slot started)
completed <-> no_show                   (outcome relabel)
completed | no_show -> confirmed        (revert a mis-click)

Terminal bookings and the session lifecycle (#295 item 8)

A booking that reaches canceled KEEPS ITS ROW. That is what puts it in the Declined & Canceled lane, and it is a database invariant, not a UI one.

It was not true until 2026-08-11. bookings.session_id is ON DELETE CASCADE, and five functions deleted the booking_sessions row as cleanup once no LIVE bookings remained on it. The cascade took the row they had just marked canceled with it, so the card vanished from the board entirely instead of moving lanes, and the host read that as "deleted". Decline hit it every time (a pending booking has no calendar event, which was the collapse condition), so the lane had never held a declined booking at all.

The rule now, in 20260811120000_terminal_bookings_survive_session_collapse.sql: the session row may only be deleted when no booking of ANY status references it. Applied identically in decline_booking_in_session, cancel_confirmed_booking, remove_attendee_from_session, delete_booking_in_session, and clear_session_calendar_event (that last one is the path a cancel takes when the session had a real Google event: the route deletes the event, then calls it, and its own collapse fired for the same reason).

Three consequences worth knowing:

  • did_collapse_session now means the session row really was removed. In practice only delete_booking_in_session returns TRUE, on the last booking. No client reads it for control flow.

  • The pending-episode cleanup is unchanged and still lives inside the same branch. The surviving booking simply loses episode_id through that FK's ON DELETE SET NULL, which laneForBooking already handled.

  • Sessions outlive their bookings, so their max_bookings, creates_episode and episode_id would too. create_booking_with_attendees therefore resets a reused session to the state a brand-new row would have (the link's current capacity and mode, and a NULL episode) when every booking on it is canceled. A session that still holds a live booking keeps its snapshot, as #295 item 1 requires.

    The decision is made under a lock on the session's booking rows, not just on the session row. POST /api/bookings/:id/status stamps an outcome with only an optimistic guard on the booking row and takes no booking_sessions lock, so the session lock alone does not hold it off: a confirmed to completed commit landing between the count and the insert would leave the guard below evaluated on stale state. Taking FOR UPDATE on the session's booking rows makes that writer block until the booking is created, which serialises the two paths without changing the outcome route. S then B is the lock order every sibling RPC uses.

    Three further details are load-bearing. The gate is "every booking canceled", not "no pending/confirmed bookings": a completed or no-show booking occupies zero capacity while being recorded history riding the session, and resetting under it would detach a finished recording's episode. Skipping the reset is not enough by itself, so a slot carrying a settled booking now rejects new bookings with session_full: without that the function would carry on and attach the new booking, and an episode_guests row per attendee, to the finished recording's episode, which is the guest-portal access boundary. The public route cannot reach that state (a settled slot is in the past, and the route validates availability first), so the guard is the boundary for any caller reaching the RPC directly. And episode_id is cleared in BOTH modes, because the episode mint only fires on a NULL id, so retaining it would attach the next booker and their episode_guests to the cancelled booking's episode. The old episode's episodes.booking_id lookup hint is cleared with it, or that episode would still resolve to the slot through the fallback in the episode page's session load and render the new booker's guests. Every case is pinned in terminal_bookings_survive.test.sql.

Nothing is blocked by the surviving session row: the slot's capacity check counts only pending and confirmed bookings, so the slot reopens as before.

Drag semantics

actionForMove(booking, targetLane) is the single source of truth for both the droppable accept rules and the drag-end dispatcher (BookingsBoard.svelte wraps the lanes in a dnd-kit DragDropProvider). Legal drags are pure triage gestures on live bookings:

FromToAction
pendingconfirmedconfirm
pendingcanceleddecline
confirmed (upcoming only)canceledcancel

Everything else returns null: Date Passed, Published, Canceled, and Sent never emit or accept drags, and outcome stamps and reverts never change lanes, so no drag exists for them. canDragBooking() allows pickup only from Pending and Confirmed. availableActions() is the keyboard-reachable superset: every drag action must also be reachable from the card menu and detail sheet, and outcome/revert actions are ONLY reachable there. Delete is never a lane; it is a menu action with its own confirmation.

Dropping dispatches through the page's handleAction(): confirm, decline, cancel, and delete open a confirmation dialog (BookingActionDialogs.svelte); reschedule opens the reschedule dialog; outcome stamps apply immediately (low stakes, reversible) via setBookingStatus() and then silently reload the board.

Calendar view

BookingsCalendarView.svelte renders four layouts over the same BoardBooking[]: a Monday-start month grid (42 cells), week and day time grids (both CalendarTimeGrid.svelte), and a schedule (agenda) list for the cursor month. Chips and blocks are colored by BOOKING_STATUS_META (with PUBLISHED_META when the lane derivation says published); the month grid shows at most 3 chips per day with a "+N more" popover, and every booking opens the same detail sheet.

Date math and the time-grid layout live in calendar-utils.ts (pure, unit-tested): shiftCursor steps the anchor day by the active view's period (month/schedule page by month, week by 7 days, day by 1), gridWindow sizes the visible hours (08:00–18:00 default, stretched to fit out-of-hours bookings), and layoutDayEvents assigns overlap columns cluster-by-cluster so concurrent bookings split the width while isolated ones stay full-width. Short slots are padded to MIN_EVENT_MINUTES for display only; bookings crossing midnight clamp to their start day (all views bucket by local start day).

The layout choice persists per device via $lib/stores/bookings-calendar-view.svelte.ts (podcasterplus-bookings-calendar-view, same pattern as the grid/list view stores). With nothing stored, initializeView() defaults narrow viewports (max-width: 639px) to schedule and everything else to month; only an explicit setView persists, so the default keeps following the device. Navigation is local state; no extra fetches (the hub loads all bookings once, default list limit 500). The reactive now prop drives the today ring, the time grids' now line, and status meta.

Detail sheet data flow

BookingDetailSheet.svelte receives the selected BoardBooking from the page (no fetch of its own) and re-derives lane, status meta, awaiting-outcome, and the action set. After any board reload the page re-points the open sheet at the fresh row (bookings.find(...)), so it never shows stale data. It renders schedule (with a viewer-timezone hint when it differs from the booking's), meeting link, linked episode link, attendees with roles, guest notes, custom form answers (buildCustomFieldAnswers), AI research answers (buildAiResearchAnswers against the link's parsed config), cancellation details, and history stamps. A calendar_event_status = 'failed' session on a confirmed booking surfaces an alert with a Send meeting invite button, wired by the page to retryCalendarEvent()POST /api/booking-sessions/:id/create-calendar-event (see the sessions API).

That button used to live only on the episode's guests card. It moved here in #295 item 1 because a general booking has no episode, so that card was unreachable and the alert dead-ended — and the same was true for any booking whose episode had been deleted. The sheet stays fetch-free: the page owns the call, the in-flight flag, and the board reload.

Outcome stamps

POST /api/bookings/:id/status (admin+) is the first writer of the two dormant booking_status values, completed and no_show. It is a single-table guarded UPDATE with no session/calendar/email side effects, so there is deliberately no RPC: the middleware chain enforces admin and the guarded UPDATE enforces the transition graph above (outcomes only after the slot has started). A concurrent change surfaces as a 409 asking the host to refresh.

Reschedule flow

RescheduleDialog.svelte reuses the PUBLIC availability endpoints for its picker: GET /api/availability/month (returns availableDates as { date, slots } objects plus the authoritative link duration/timezone) and GET /api/availability for a day's slots. The current slot is filtered out as a target. The parent remounts the dialog per booking ({#key booking?.id}); all picker state initializes at component creation, deliberately avoiding effect-driven resets.

Submission hits POST /api/bookings/:id/reschedule (admin+), which is a direct host move, solo sessions only:

  1. reschedule_booking_solo RPC moves the session row in place. GATE-4 walls raise from the episode recording-date update and map to the same upsell responses as confirm: 429 month_full, 422 recording_window. Expected refusals return as codes: shared_session (409: group bookings cannot be moved as a unit; cancel and rebook) and slot_taken (the dialog re-loads the day's slots).
  2. The Google event is PATCHed to the new time (same event); on failure the response reports calendar_updated: false and the UI appends a warning to the success toast.
  3. Time-based automation jobs are re-armed (cancelPendingTimeBasedJobs then scheduleTimeBasedJobsForBooking) and booking.rescheduled is emitted.
  4. Every live attendee gets a reschedule email with the old and new times.

There is no guest-acceptance step: the move takes effect immediately and attendees are notified.

Guest Network invite conversion

The Sent lane renders PodcastInvite rows from GET /api/guest-network/invitations/podcast. InviteCard.svelte shows the invite status (pending | accepted | declined | expired, with an expires-soon hint inside 3 days), the booking link it was sent for, and menu actions (view network profile, copy booking URL). When the invite has a converted_booking_id that is present on the current board, the card shows a violet "Booked" badge and a "View booking" affordance that scrolls the converted booking's card into view and flash-highlights it. Invites are additive context: a failed invites fetch degrades the Sent lane, never the page, and the Sent lane never participates in drag and drop.

Page-level state

+page.svelte owns: URL-synced tabs (?tab=board|calendar, replaceState), client-side search (guest name/email, episode title, attendees) and booking-link filter applied to both bookings and invites, a silent-refresh reload path used after every mutation, and the loading/error/empty states (empty state points at booking links and the Guest Network).

Internal documentation - Not for public distribution