CU-11: Public booking page UX: timezones, calendar nav, empty-state, QR, mobile

Priority: P1 Accounts/sessions: Anonymous invitee experience for most steps (no login needed to view/book a public page). One sub-section (QR code) requires the P1 dashboard session (ravikantguptaofficial@gmail.com, signed into Calendo via Sign in with Google) because — see the IMPORTANT note in Goal — the QR feature lives in the dashboard, not on the public confirmation page. Parallel-safe: Yes. This suite only reads the public page, navigates the calendar, changes the timezone client-side, and makes at most ONE throwaway booking. It does not edit global host availability or event types. Exclusive (rewrites global host availability?): No. Estimated time: 22 minutes L3 reality checks: None required. One optional booking is made to reach the confirmation screen; its L3 calendar/email reality is NOT in scope for this suite (covered by CU-01/CU-02). This suite stops at the in-UI confirmation screen and cleans the booking up.

Goal

This suite proves the invitee-facing booking page — the screen the overwhelming majority of Calendo users actually see — is usable, correct, and not broken. It verifies the timezone picker actually shifts displayed slot times, the month calendar navigates forward/back and correctly marks days available vs unavailable, that empty availability shows a clear human message instead of a blank page, that the booking page honors the host's brand color, that the layout collapses sensibly on a 375px phone, and that a happy-path booking reaches the animated "Booking Confirmed!" screen with working add-to-calendar buttons. It also verifies the QR-code feature renders and is downloadable.

IMPORTANT GROUNDING NOTE (read before executing): The original coverage brief said "QR code on the confirmation page." That is incorrect against the current code. In /Users/ravf/projects/calendo/public/booking/index.html the confirmation view (#confirmation-success-view) contains add-to-calendar buttons and cancel/reschedule links but NO QR code. The QR code is a HOST dashboard feature (#qrLinkBtn in the booking-link bar, and per-event showEventQR() buttons, rendered into the #qrModal / #qrCanvas with a downloadQR() button). This runbook therefore tests QR where it really lives (the dashboard, as P1) and the agent must FLAG if it ever finds a QR element on the public confirmation page or fails to find one on the dashboard. Do not invent a QR element on the public page.

Preconditions

Test data

Steps

Section A — Resolve the host slug and base booking URL (host session)

  1. Action: Open the dashboard (https://calendo.dev/dashboard/). If it redirects to a login page, STOP this section, FLAG precondition failure, and jump to Section B using a known HOST_SLUG from 00-setup-preconditions.md if one is documented; otherwise skip QR (Section F) and proceed with the rest using that documented slug. -> Expect: The dashboard loads showing the left sidebar with tabs (data-tab values: overview, bookings, calendar, analytics, availability, routing-forms, contacts, settings, event-types) and the Overview tab is active. -> [L1]
  2. Action: On the Overview tab, find the public booking link displayed in the share bar (the value element #bookingLinkValue). Read its full text. -> Expect: A full URL like https://calendo.dev/booking/?user=<HOST_SLUG>. Record HOST_SLUG (everything after ?user=). -> [L2]
    • Capture screenshot: A2-dashboard-booking-link.
  3. Action: Construct BASE_BOOKING_URL = https://calendo.dev/booking/?user=<HOST_SLUG>&event=<EVENT_SLUG> using the slug from Section A and EVENT_SLUG=30-minute-meeting. -> Expect: A well-formed URL you will reuse below. -> [L1]

Section B — Timezone picker shifts displayed slot times

  1. Action: In a fresh anonymous browser tab open BASE_BOOKING_URL. -> Expect: The booking view (#booking-view) becomes visible, showing the left event sidebar, a month calendar (#calendar), and a timezone selector (#timezone-select) in the calendar header. The step indicator (#step-indicator) reads "Select Time -> Your Details -> Confirmation". -> [L1]
  2. Action: Read the current value of the timezone selector (#timezone-select). If it is not already America/Los_Angeles, select the option "America/Los Angeles" (value America/Los_Angeles). -> Expect: The selector shows America/Los Angeles. The calendar reloads availability for that timezone (a brief load). The selector contains many options (more than 1) including UTC, Europe/London, Asia/Kolkata, etc. -> [L1]
  3. Action: Click the first calendar day that is selectable (.calendar-day.available); if the current month has none, click the next-month arrow (.calendar-nav-btn, the LAST one = right arrow ) up to 3 times until an available day appears, then click it. -> Expect: The time-slots panel (#time-slots-panel) becomes visible. It shows a date header (#time-slots-date), a timezone label (#time-slots-tz-label) reading "Times in America/Los Angeles", and one or more time-slot buttons (.time-slot). -> [L1]
    • Capture screenshot: B6-slots-LA.
  4. Action: Record the exact text of the FIRST time-slot button and the SELECTED day number, so you can compare after switching timezone. -> Expect: A 12-hour time like "9:00am" (format from formatTime12h). -> [L1]
  5. Action: Change the timezone selector (#timezone-select) to "Asia/Kolkata" (value Asia/Kolkata). -> Expect: The time-slots panel collapses (selection resets) and the calendar reloads for the new timezone. -> [L1]
  6. Action: Re-select an available day (same approach as step 6) and read the new time-slots panel. -> Expect: (a) The timezone label (#time-slots-tz-label) now reads "Times in Asia Kolkata". (b) The displayed slot times are DIFFERENT from step 7 — Asia/Kolkata is 12.5 hours ahead of Los Angeles, so the same underlying availability appears at clearly different clock times (and often on a different calendar day). The displayed times must not be identical to the LA times. -> [L1]
    • Capture screenshot: B9-slots-Kolkata.
  7. Action: (Robustness) Confirm the timezone change actually re-queried the server, not just relabeled. Compare the set of available day numbers before vs after the switch, or confirm the first slot time changed by a non-zero, non-24h amount. -> Expect: The displayed local times shifted consistent with a ~12.5h offset (e.g. an LA-morning slot now reads as a late-evening/next-day Kolkata time). -> [L1]

Section C — Empty-availability shows a clear message (not a blank page)

  1. Action: Open https://calendo.dev/booking/?user=<HOST_SLUG>&event=<EMPTY_EVENT_SLUG> (using EMPTY_EVENT_SLUG=cu11-empty if it exists). If no reserved empty event exists, instead use the happy-path event and click the NEXT-month arrow (.calendar-nav-btn last) repeatedly to reach a far-future month, OR pick a single specific day that is unavailable — the goal is to land on a state with zero .available days and a day with no slots. Do NOT edit P1's global availability to manufacture this state. -> Expect: The booking view loads. -> [L1]
  2. Action: Inspect the calendar grid for the displayed month. -> Expect: With a truly empty event/month, every day cell carries the class .calendar-day.unavailable (greyed #ccc) and there are zero .calendar-day.available cells. The page is NOT blank — host sidebar, month label, and timezone selector are still present. -> [L1]
  3. Action: If you can reach a selectable but slot-less day (group/override edge), click it; otherwise verify the per-day empty message directly: the code renders <div class="time-slots-empty">No available times</div> into #time-slots-list when a selected day has zero slots. -> Expect: Where applicable, the time-slots panel shows the literal text "No available times" (.time-slots-empty), not an empty box. -> [L1]
  4. Action: (Event-list empty state, additional coverage) Open https://calendo.dev/booking/?user=<HOST_SLUG> WITHOUT an &event= param ONLY IF P1 is known to have at least one event (it does) — this shows the event LIST, not an empty state. To genuinely see the "no event types" empty state you need a host with zero events, which P1 is not. So instead just VERIFY the event-list renders cards (.event-card) for P1. The "No meeting types available yet." empty branch (rendered into #event-types-grid) cannot be triggered on P1 without removing all events — note this in Manual residue, do not delete P1's events. -> Expect: #event-list-view visible, #event-types-grid contains at least one .event-card. -> [L1]
    • Capture screenshot: C12-empty-availability.

Section D — Calendar navigation and day states

  1. Action: Reopen BASE_BOOKING_URL (happy-path event) in an anonymous tab and wait for #calendar to render. Read the current month label text (.calendar-month). -> Expect: A label like "June 2026". -> [L1]
  2. Action: Confirm the back/previous arrow behavior: locate the first .calendar-nav-btn (left arrow ). On the current real-time month it should be disabled (the code disables prev when month <= now.getMonth() in the current year). -> Expect: The left/prev nav button is disabled (greyed, not clickable) while viewing the current month — you cannot navigate into the past. -> [L1]
  3. Action: Click the next-month arrow (the LAST .calendar-nav-btn, right arrow ). -> Expect: The month label (.calendar-month) changes to the following month. Availability reloads. -> [L1]
  4. Action: Now that you are on a future month, the previous arrow should be enabled. Click the FIRST .calendar-nav-btn (left ) to go back one month. -> Expect: The month label returns to the original month from step 15. -> [L1]
    • Capture screenshot: D18-calendar-nav.
  5. Action: Verify day states in the visible month: there must be at least one .calendar-day.available (clickable, dark text/bold) and at least one .calendar-day.unavailable (greyed). Hover an available day. -> Expect: Available days exist and react to hover; unavailable days are visually disabled (#ccc) and not clickable. The current date carries .today (with a dot indicator). -> [L1]
  6. Action: Click an available day, confirm slots appear, then verify the selected day gets the .selected class (brand background). -> Expect: #time-slots-panel shows, the clicked day has class .calendar-day.selected with the brand-colored background, and .time-slot buttons render. -> [L1]

Section E — Branding / colors applied

  1. Action: With the booking page open (Section D), determine P1's configured brand color. In the dashboard (Section A context) this is the booking-page brand color setting; the public page applies it via applyBranding() setting the CSS variable --brand from page.brand_color. Inspect the computed color of a primary brand element — e.g. a .time-slot button border/text or the selected calendar day background (both use var(--brand)). -> Expect: The brand color is applied consistently: time-slot accents and the selected-day background share the same brand color. If P1 has set a custom (non-default) brand color, it must be that custom value, NOT the default #0069ff. If P1 has no custom color, the default #0069ff is acceptable. -> [L1]
    • Capture screenshot: E21-branding.
  2. Action: Confirm the "powered by Calendo" badge (.powered-by) at the bottom of the page is present (unless P1 has hide_branding enabled, in which case it is correctly hidden). -> Expect: Either the badge shows "powered by Calendo" with the logo, or — if hide_branding is on — it is absent. Note which case applies. -> [L1]

Section F — QR code renders and downloads (host dashboard; requires P1 session)

  1. Action: Go to the dashboard Overview tab (https://calendo.dev/dashboard/). In the booking-link share bar, click the "QR" button (#qrLinkBtn). -> Expect: The QR modal (#qrModal) opens (gets class active), showing the booking URL text (#qrModalUrl) and a rendered QR image drawn into the canvas (#qrCanvas, 300x300). The QR must be a real rendered code (canvas has non-blank pixels), NOT a broken image. The code must be generated client-side — there must be NO external api.qrserver.com network request. -> [L1]
    • Capture screenshot: F23-qr-modal.
  2. Action: Click the "Download" button in the modal (#qrDownloadBtn, calls downloadQR()). -> Expect: A PNG download is triggered (a .png file from the canvas via toDataURL). Confirm the browser registers a download. -> [L1]
  3. Action: Close the QR modal (Close button), navigate to the Event Types tab (sidebar a[data-tab="event-types"]), and on any event card click its per-event "QR" button (calls showEventQR(<id>)). -> Expect: The same #qrModal opens with #qrModalUrl showing that event's deep booking link (...&event=<slug>) and a freshly rendered #qrCanvas. -> [L1]
    • Capture screenshot: F25-event-qr.
  4. Action: (Negative check on the original brief) Verify there is NO QR element on the PUBLIC confirmation page. After completing Section G, on the #confirmation-success-view, search for any QR canvas/image. -> Expect: No QR element exists on the public confirmation page (only add-to-calendar buttons #calendar-links and manage links #confirmation-manage). If a QR unexpectedly appears, FLAG it — the code does not put one there. -> [L1]

Section G — Mobile layout (375px) + one happy-path booking to the confirmation screen

  1. Action: Resize the browser/emulate a phone viewport at 375px wide (iPhone width). Open BASE_BOOKING_URL. -> Expect: The page renders without horizontal overflow. The desktop left sidebar (#booking-sidebar) is hidden, and the mobile host card (#mobile-host-card) appears at the top with the host name and event. The calendar and time-slots stack vertically (time-slots panel #time-slots-panel goes full width below the calendar, not side-by-side). The timezone selector is still reachable. -> [L1]
    • Capture screenshot: G27-mobile-375.
  2. Action: At 375px, tap the mobile host card header (#mobile-host-toggle) to expand it. -> Expect: The host details (#mobile-host-details) expand showing duration/location/description; the toggle chevron (#mobile-toggle-btn) rotates. -> [L1]
  3. Action: Still at 375px, select an available day, then tap a time slot (.time-slot). -> Expect: The confirm form (#confirm-view) appears with name (#input-name) and email (#input-email) fields, the selected date/time summary (#confirm-datetime-text), and a "Confirm Booking" button (#btn-confirm). The form is usable at phone width (fields full-width, buttons stacked per the mobile .form-actions { flex-direction: column }). -> [L1]
  4. Action: Fill name CU11 Invitee <RUNID> (#input-name) and email ravikantguptaofficial+inv-<RUNID>@gmail.com (#input-email), then click "Confirm Booking" (#btn-confirm). -> Expect: The confirmation success view (#confirmation-success-view) appears with the animated check and title "Booking Confirmed!" (.confirmation-title). The booking details (#confirmation-details) reference the event (contains "30"). Add-to-calendar buttons (#calendar-links: Google Calendar, Outlook, Download .ics) are visible, and cancel/reschedule manage links (#confirmation-manage) are present. -> [L1]
    • Capture screenshot: G30-confirmation.
  5. Action: Confirm the add-to-calendar buttons are real links. Read the href of the "Download .ics" button (a download="booking.ics" link with a blob URL) and the Google Calendar button (https://calendar.google.com/calendar/r/eventedit?...). Do NOT click through to actually create external calendar events (out of scope for this suite). -> Expect: The .ics link is a downloadable blob; the Google/Outlook links point to the correct calendar compose URLs with the event title and times. -> [L1]
  6. Action: Capture the cancel link href from #confirmation-manage (it contains /booking/cancel.html?token=) for use in Cleanup. -> Expect: A cancel URL with a token query param. Record it. -> [L2]

L3 reality checks

None — see Pass/Fail. This suite deliberately stops at the in-UI confirmation screen. It does NOT verify the Google Calendar event or the confirmation email (those are owned by CU-01/CU-02). The single booking created here is throwaway and is cancelled in Cleanup.

Cleanup

  1. Cancel the one booking created in Section G: open the cancel URL recorded in step 32 (https://calendo.dev/booking/cancel.html?token=<token>), confirm the cancellation in that page's UI, and verify it reports the booking as cancelled. -> [L2]
  2. As a backstop, in the dashboard go to the Bookings tab (sidebar a[data-tab="bookings"]), search for inv-<RUNID> or CU11 Invitee <RUNID>, and confirm the booking shows status Cancelled (or is gone). If it is still active, cancel it from there. -> [L2]
  3. No event types, forms, polls, throwaway accounts, or availability changes were created by this suite, so nothing else to delete. If Section C used the reserved cu11-empty event, leave it in place (it is a shared fixture, not per-run state).
  4. Close any QR modal and reset the browser viewport to desktop width.

Pass/Fail criteria

The run PASSES only if ALL of the following are true:

The run FAILS if: timezone change does not shift times; calendar navigation is broken or lets you go into the past; empty availability shows a blank/error page; the QR canvas is blank or pulls from an external QR API; the mobile layout overflows horizontally or does not collapse the sidebar; or the booking does not reach the confirmation screen.

Evidence to capture

Manual residue / cannot-verify