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
- The host P1 (ravikantguptaofficial@gmail.com) already exists, has at least one published event type, and has baseline weekly availability so the public page shows bookable slots. The reliable default event slug for a fresh account is
30-minute-meeting; this suite uses that unless the live dashboard shows it was renamed. - The P1 Calendo dashboard session must be active for the QR section (Section F). If https://calendo.dev/dashboard/ redirects to a login screen instead of showing the dashboard, that is a precondition failure: FLAG it (see 00-setup-preconditions.md), skip Section F only, and continue the rest of the suite (Sections A–E and G are anonymous and do not need login). Do NOT perform a cold Google password login mid-run.
- The host slug (HOST_SLUG) is read live from the dashboard booking link
#bookingLinkValue(Section A). Do not hardcode it. - For the empty-state test (Section C) you need an event whose nearest months have NO availability. Per 00-setup-preconditions.md a reserved "always-empty" event slug may exist (e.g.
cu11-empty). If it does not exist, follow the documented fallback in Section C and do not improvise by editing P1's global availability. - If any required precondition is missing, FLAG it as a precondition failure and stop the affected section. Do not improvise account state.
Test data
- RUNID: pick one fresh UTC token at execution start, format
YYYYMMDD-HHMM, e.g.20260601-1530. Reuse the exact same RUNID for every value below. - HOST_SLUG: read live from
#bookingLinkValuein the dashboard (Section A). Example shape: the path segment after?user=. - EVENT_SLUG (happy path):
30-minute-meeting(the default event for a fresh P1 account). If the dashboard shows it was renamed, use the slug of any published event that currently shows available days. - EMPTY_EVENT_SLUG:
cu11-emptyif the reserved empty-availability event exists; otherwise see Section C fallback. - Invitee identity (only for the one happy-path booking in Section G):
- Name:
CU11 Invitee <RUNID> - Email:
ravikantguptaofficial+inv-<RUNID>@gmail.com(plus-alias so it lands in the single shared inbox and is searchable byinv-<RUNID>).
- Name:
- Timezones used for the picker test (Section B): start in
America/Los_Angeles, switch toAsia/Kolkata. These have a large, unambiguous offset (12.5h) so a shift in displayed times is obvious.
Steps
Section A — Resolve the host slug and base booking URL (host session)
- 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-tabvalues: overview, bookings, calendar, analytics, availability, routing-forms, contacts, settings, event-types) and the Overview tab is active. -> [L1] - 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 likehttps://calendo.dev/booking/?user=<HOST_SLUG>. Record HOST_SLUG (everything after?user=). -> [L2]- Capture screenshot:
A2-dashboard-booking-link.
- Capture screenshot:
- 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
- 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] - Action: Read the current value of the timezone selector (
#timezone-select). If it is not alreadyAmerica/Los_Angeles, select the option "America/Los Angeles" (valueAmerica/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] - 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.
- Capture screenshot:
- 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] - Action: Change the timezone selector (
#timezone-select) to "Asia/Kolkata" (valueAsia/Kolkata). -> Expect: The time-slots panel collapses (selection resets) and the calendar reloads for the new timezone. -> [L1] - 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.
- Capture screenshot:
- 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)
- Action: Open
https://calendo.dev/booking/?user=<HOST_SLUG>&event=<EMPTY_EVENT_SLUG>(using EMPTY_EVENT_SLUG=cu11-emptyif it exists). If no reserved empty event exists, instead use the happy-path event and click the NEXT-month arrow (.calendar-nav-btnlast) 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.availabledays and a day with no slots. Do NOT edit P1's global availability to manufacture this state. -> Expect: The booking view loads. -> [L1] - 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.availablecells. The page is NOT blank — host sidebar, month label, and timezone selector are still present. -> [L1] - 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-listwhen 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] - 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-viewvisible,#event-types-gridcontains at least one.event-card. -> [L1]- Capture screenshot:
C12-empty-availability.
- Capture screenshot:
Section D — Calendar navigation and day states
- Action: Reopen BASE_BOOKING_URL (happy-path event) in an anonymous tab and wait for
#calendarto render. Read the current month label text (.calendar-month). -> Expect: A label like "June 2026". -> [L1] - 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 whenmonth <= 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] - 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] - 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.
- Capture screenshot:
- 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] - Action: Click an available day, confirm slots appear, then verify the selected day gets the
.selectedclass (brand background). -> Expect:#time-slots-panelshows, the clicked day has class.calendar-day.selectedwith the brand-colored background, and.time-slotbuttons render. -> [L1]
Section E — Branding / colors applied
- 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--brandfrompage.brand_color. Inspect the computed color of a primary brand element — e.g. a.time-slotbutton border/text or the selected calendar day background (both usevar(--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#0069ffis acceptable. -> [L1]- Capture screenshot:
E21-branding.
- Capture screenshot:
- Action: Confirm the "powered by Calendo" badge (
.powered-by) at the bottom of the page is present (unless P1 hashide_brandingenabled, 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)
- 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 classactive), 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 externalapi.qrserver.comnetwork request. -> [L1]- Capture screenshot:
F23-qr-modal.
- Capture screenshot:
- Action: Click the "Download" button in the modal (
#qrDownloadBtn, callsdownloadQR()). -> Expect: A PNG download is triggered (a.pngfile from the canvas viatoDataURL). Confirm the browser registers a download. -> [L1] - 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 (callsshowEventQR(<id>)). -> Expect: The same#qrModalopens with#qrModalUrlshowing that event's deep booking link (...&event=<slug>) and a freshly rendered#qrCanvas. -> [L1]- Capture screenshot:
F25-event-qr.
- Capture screenshot:
- 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-linksand 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
- 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-panelgoes full width below the calendar, not side-by-side). The timezone selector is still reachable. -> [L1]- Capture screenshot:
G27-mobile-375.
- Capture screenshot:
- 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] - 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] - Action: Fill name
CU11 Invitee <RUNID>(#input-name) and emailravikantguptaofficial+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.
- Capture screenshot:
- Action: Confirm the add-to-calendar buttons are real links. Read the
hrefof the "Download .ics" button (adownload="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.icslink is a downloadable blob; the Google/Outlook links point to the correct calendar compose URLs with the event title and times. -> [L1] - 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
- 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] - As a backstop, in the dashboard go to the Bookings tab (sidebar
a[data-tab="bookings"]), search forinv-<RUNID>orCU11 Invitee <RUNID>, and confirm the booking shows status Cancelled (or is gone). If it is still active, cancel it from there. -> [L2] - 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-emptyevent, leave it in place (it is a shared fixture, not per-run state). - 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:
- Timezone picker (
#timezone-select) offers many timezones and switching from America/Los_Angeles to Asia/Kolkata visibly changes the displayed slot times and the "Times in ..." label (#time-slots-tz-label); the times are NOT identical across the two timezones. - Calendar next arrow advances the month label and the previous arrow returns to it; the previous arrow is disabled on the current real-time month (cannot navigate into the past).
- The visible month contains at least one
.calendar-day.available(clickable) and at least one.calendar-day.unavailable(greyed, not clickable); selecting a day reveals time slots and applies.selected. - The empty-availability state shows clear human text — either all-
.unavailabledays for an empty event/month and/or the literal "No available times" (.time-slots-empty) on a slot-less day — and the page is NOT blank (host info, month label, timezone selector still present). - The host brand color is applied consistently (selected-day background and time-slot accents share
var(--brand)); the "powered by Calendo" badge state is consistent with the host's hide_branding setting. - The QR code renders as a real client-side canvas (
#qrCanvas) in the dashboard QR modal (#qrModal) for both the booking-link QR (#qrLinkBtn) and a per-event QR (showEventQR), with NOapi.qrserver.comexternal dependency, and the Download button triggers a PNG download. (If the P1 dashboard session is absent, this criterion is WAIVED and the section is FLAGGED, not failed.) - At 375px the desktop sidebar is hidden, the mobile host card shows, calendar and time-slots stack vertically, there is no horizontal overflow, and the booking form is usable.
- One happy-path booking reaches "Booking Confirmed!" (
.confirmation-title) with working add-to-calendar buttons and cancel/reschedule links; the public confirmation page has NO QR element. - Cleanup cancelled the created booking.
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
A2-dashboard-booking-link— dashboard share bar showing#bookingLinkValue.B6-slots-LAandB9-slots-Kolkata— same event, two timezones, showing the time shift and the differing "Times in ..." labels.C12-empty-availability— the clear empty-availability message / all-unavailable month.D18-calendar-nav— month label after forward+back navigation (and note the disabled prev arrow on the current month).E21-branding— brand color applied to slots/selected day.F23-qr-modalandF25-event-qr— rendered QR canvases in the dashboard modal.G27-mobile-375— the 375px stacked layout with mobile host card.G30-confirmation— the "Booking Confirmed!" screen with add-to-calendar buttons.- Notes: recorded HOST_SLUG, RUNID, the LA vs Kolkata first-slot times, the cancel URL token, and the hide_branding/brand-color state observed.
Manual residue / cannot-verify
- The "No meeting types available yet." event-list empty branch (
#event-types-gridempty state) cannot be triggered on P1 without deleting all of P1's event types, which this suite must not do. A human can verify it on a deliberately empty test host, or it can be left to a unit/E2E test. (Out of scope here.) - Whether the QR PNG file actually downloaded to disk and is a scannable code resolving to the correct booking URL — the agent can confirm a download was triggered and the canvas rendered, but cannot scan the saved PNG. Hand off to human if a real-device QR scan is desired.
- The exact custom brand color P1 has configured (if any) is read from the live page; if the agent cannot reliably read the dashboard branding setting, a human should confirm the intended brand color matches what the public page renders.
- Pixel-perfect mobile rendering and touch ergonomics beyond "no overflow / sidebar collapses / form usable" are subjective and left to human review.
- L3 external reality (real Google Calendar event + confirmation email for the Section G booking) is intentionally NOT verified here; see CU-01/CU-02.