CU-15: Contacts, analytics dashboard, and CSV export
Priority: P2 Accounts/sessions: P1 host only (ravikantguptaofficial@gmail.com, already signed into Calendo via "Sign in with Google" and active at https://calendo.dev/dashboard/) Parallel-safe: Yes — this suite is read-mostly and only creates a small number of scoped bookings via "Book on Behalf"; it does not touch global availability, OAuth, or other suites' data. Exclusive (rewrites global host availability?): No. Estimated time: 25 minutes L3 reality checks: None. This suite verifies in-app aggregation, dashboard metrics, and the downloaded CSV file (a true L2 artifact check). It does NOT send invitee emails or write to Google/Outlook Calendar, so there is no external-reality (L3) step.
Goal
This suite proves that a Calendo host can review their relationship and performance data without leaving the dashboard: the Contacts tab correctly aggregates every person who has ever booked (one row per unique invitee email, with meeting counts and last/next meeting), and its All / New / No-upcoming filters plus free-text search behave correctly; the Analytics tab surfaces the headline metrics (total bookings, this-month count, cancellation rate, no-show rate, most-popular event type) plus the 30-day volume chart and per-event-type breakdown; the Export CSV control downloads a real bookings CSV whose columns and rows can be opened and verified to include this run's bookings, with the range selector (30d/90d/1y/all) scoping the file; and that routing-form and poll analytics surfaces render. This matters because reporting and exportability are table-stakes for any host deciding whether to trust Calendo with their scheduling, and a silently-wrong metric or a broken export erodes that trust immediately.
Preconditions
- The browser must be in an active, logged-in P1 session at https://calendo.dev/dashboard/. If hitting that URL bounces to https://calendo.dev/auth/login.html or shows a logged-out state, STOP and FLAG this as a precondition failure per
00-setup-preconditions.md. Do NOT attempt a cold email/password or Google password login mid-run. - P1 must have at least one active event type (the default "30 Minute Meeting" suffices). If the dashboard Overview shows zero event types, FLAG it — without an event type you cannot create the bookings this suite needs and analytics will be empty.
- This suite SEEDS its own data using "Book on Behalf" (dashboard Bookings tab), so it does not depend on pre-existing bookings — but it does assume P1 already has some booking history so the "aggregation across past bookings" assertion is meaningful. If the account is completely empty, the suite still passes by verifying the freshly-seeded rows appear; note in evidence that history was empty.
- If any required UI element named in a step is absent (e.g. the Contacts tab is missing from the sidebar), do NOT improvise an alternate path — capture a screenshot and FLAG it as a coverage gap.
Test data
RUNID convention: At execution start, pick a fresh unique token, e.g. a UTC timestamp 20260601-1530. Embed RUNID in every created name/email so reruns never collide and assertions can be scoped by RUNID.
Create the following via "Book on Behalf" on the Bookings tab (all against the existing "30 Minute Meeting" event type unless noted). Use plus-aliases of the P1 inbox so nothing escapes to real third parties:
- Booking A (normal contact): name
CU15 Alpha <RUNID>, emailravikantguptaofficial+inv-<RUNID>-a@gmail.com, start time = tomorrow 11:00 (future → should count as a "next meeting"). - Booking B (same invitee, second meeting, to prove contact aggregation/count): name
CU15 Alpha <RUNID>, emailravikantguptaofficial+inv-<RUNID>-a@gmail.com(SAME email as A), start time = tomorrow 14:00. - Booking C (CSV-escaping edge case): name
Comma, "Quote" <RUNID>, emailravikantguptaofficial+inv-<RUNID>-c@gmail.com, start time = tomorrow 16:00.
Expected derived values:
- Contacts tab should show ONE row for
ravikantguptaofficial+inv-<RUNID>-a@gmail.comwith a Meetings count of 2 (A + B), and one row for the comma/quote contact. - The "next meeting" column for the Alpha contact should be populated (both bookings are in the future and confirmed).
Steps
- Action: Go to the dashboard (https://calendo.dev/dashboard/). Expect: The app shell loads (
#appLayoutvisible,#welcomeHeadervisible) and the left sidebar shows tabs including Contacts, Analytics, and Routing Forms. [L1]
- Action: Click the Bookings sidebar tab (
.sidebar-nav a[data-tab="bookings"]). Expect: The Bookings panel becomes active (#panel-bookingshas classactive) and a bookings table container (#bookingsTableContainer) is shown. [L1]
- Action: Click + Book on Behalf (
#bookOnBehalfBtn). Expect: The inline form#bookOnBehalfFormbecomes visible with an event-type dropdown (#bobEventType), a datetime field (#bobStartTime), name (#bobName) and email (#bobEmail) fields, and a Create Booking button (#bobSubmitBtn). [L1]
- Action: Seed Booking A — in
#bobEventTypeselect "30 Minute Meeting"; set#bobStartTimeto tomorrow 11:00; fill#bobName=CU15 Alpha <RUNID>; fill#bobEmail=ravikantguptaofficial+inv-<RUNID>-a@gmail.com; click#bobSubmitBtn. Expect: The bookings table (#bookingsTableContainer) updates to containCU15 Alpha <RUNID>. [L2]
- Action: Seed Booking B — reopen the form (
#bookOnBehalfBtn), same event type,#bobStartTime= tomorrow 14:00,#bobName=CU15 Alpha <RUNID>,#bobEmail=ravikantguptaofficial+inv-<RUNID>-a@gmail.com(identical email to A), submit (#bobSubmitBtn). Expect: A second row for the Alpha invitee appears in#bookingsTableContainer. [L2] — Order matters: B must share A's exact email so the Contacts tab aggregates them into a single contact with count 2.
- Action: Seed Booking C — reopen the form, same event type,
#bobStartTime= tomorrow 16:00,#bobName=Comma, "Quote" <RUNID>,#bobEmail=ravikantguptaofficial+inv-<RUNID>-c@gmail.com, submit. Expect:#bookingsTableContainercontainsComma, "Quote" <RUNID>. [L2] (This row exercises CSV comma/quote escaping later.)- Capture screenshot:
cu15-01-bookings-seeded
- Capture screenshot:
- Action: Click the Contacts sidebar tab (
.sidebar-nav a[data-tab="contacts"]). Expect:#panel-contactsbecomes active; the header reads "Contacts / Everyone who has booked a meeting with you."; the filter bar#contactsFilterBarshows three buttons — All ([data-contacts-filter="all"], active by default), New (30d) ([data-contacts-filter="new"]), No upcoming ([data-contacts-filter="inactive"]) — and a search box#contactsSearch. A table renders in#contactsTableContainerwith columns Name, Email, Phone, Last meeting, Next meeting, Meetings. [L1]
- Action: In the Contacts table, locate the Alpha contact by email
ravikantguptaofficial+inv-<RUNID>-a@gmail.com. Expect: Exactly ONE row exists for that email (A and B are aggregated), its Name showsCU15 Alpha <RUNID>, and its Meetings column shows2. The Next meeting column is populated (a future date), not--. [L2] — This is the core "aggregates invitees from past/all bookings" assertion.- Capture screenshot:
cu15-02-contacts-alpha-count2
- Capture screenshot:
- Action: Type
CU15 Alpha <RUNID>into the search box (#contactsSearch). Expect: The table filters live (no reload) to show only the Alpha contact row; the comma/quote contact disappears. [L1]
- Action: Clear the search, then type the Alpha email fragment
inv-<RUNID>-cinto#contactsSearch. Expect: The table now shows only theComma, "Quote" <RUNID>contact, proving search matches on email as well as name. [L1]
- Action: Clear
#contactsSearchcompletely. Click the New (30d) filter ([data-contacts-filter="new"]). Expect: The button gets theactiveclass; the table shows contacts whose FIRST booking is within the last 30 days — both this run's contacts qualify (they were just created), so bothCU15 Alpha <RUNID>andComma, "Quote" <RUNID>remain visible. [L1]
- Action: Click the No upcoming filter (
[data-contacts-filter="inactive"]). Expect: The table now shows ONLY contacts with no future/confirmed meeting (next_meetingempty). This run's contacts all have FUTURE bookings, so neitherCU15 Alpha <RUNID>norComma, "Quote" <RUNID>should appear under this filter (if the account has older one-off historical contacts, those may appear). Confirm this run's two contacts are absent here. [L1] — This proves the inactive filter is wired tonext_meeting.- Capture screenshot:
cu15-03-contacts-filters
- Capture screenshot:
- Action: Click the All filter (
[data-contacts-filter="all"]) to reset. Expect: Both run contacts are visible again; total row count returns to the unfiltered set. [L1]
- Action: Click the Analytics sidebar tab (
.sidebar-nav a[data-tab="analytics"]). Expect:#panel-analyticsbecomes active. The stats grid#analyticsStatsshows five cards: Total Bookings (#statTotal), This Month (#statThisMonth), Cancellation Rate (#statCancelRate), No-Show Rate (#statNoShowRate), Most Popular (#statPopular). A 30-day Booking Volume bar chart (#volumeChart) and an Event Type Breakdown table (#eventTypeTable) render below. [L1]
- Action: Read the value in Total Bookings (
#statTotal). Expect: It is a non-negative integer ≥ 3 (the three bookings just seeded, plus any pre-existing history). Read This Month (#statThisMonth); since the run's bookings were created this month, it should be ≥ 3. [L2] — Note: these stats are all-time / this-month from/api/analytics; they are NOT affected by the export-range dropdown (see step 19).- Capture screenshot:
cu15-04-analytics-stats
- Capture screenshot:
- Action: Read Cancellation Rate (
#statCancelRate) and No-Show Rate (#statNoShowRate). Expect: Both are percentages of the formN%(0% is valid — none of this run's bookings are cancelled or no-show). The values must be between 0% and 100% and internally sane (cancellation rate should not exceed 100%). Read Most Popular (#statPopular) — it should name the event type with the most bookings (likely "30 Minute Meeting" after seeding). [L2]
- Action: Inspect the Event Type Breakdown table (
#eventTypeTable). Expect: It has columns Event Type, Bookings, Cancel Rate, No-Show Rate; the "30 Minute Meeting" row shows a Bookings count that includes this run's 3 bookings (≥ 3). The Bookings number for the most-popular event type should match or be consistent with#statPopular. [L2]
- Action: Inspect the Booking Volume chart (
#volumeChart). Expect: It renders 30 day-columns (last 30 days). At least the column for tomorrow's date is NOT what's plotted (the chart is keyed oncreated_at, i.e. when the booking was made), so today's column should show a bar with count ≥ 3 reflecting the just-created bookings. Confirm a non-empty bar exists for today and the chart is not the "No booking data yet." empty state. [L2]- Capture screenshot:
cu15-05-analytics-chart-and-breakdown
- Capture screenshot:
- Action: Verify the range selector's scope. Note the on-screen
#statTotalvalue. Open the export range dropdown (#exportRange) and switch it among "Last 30 days", "Last 90 days", "Last year", "All time". Expect: The on-screen stats (#statTotal,#statThisMonth, etc.) and the chart do NOT change when you change#exportRange— this dropdown ONLY scopes the CSV download, not the dashboard metrics. Record this behavior explicitly. [L1] — This is a known, intentional quirk:/api/analyticsalways returns all-time numbers; the range param is consumed only by/api/analytics/export. If changing the dropdown DOES alter the on-screen numbers, that is a behavior change from the verified code and should be FLAGGED.
- Action: Set
#exportRangeto Last 30 days, then click Export CSV (#exportCsvBtn). Expect: A file download begins; the browser saves a file namedcalendo-bookings-<YYYY-MM-DD>.csv(today's date). The agent must capture the saved file path. [L2]- Capture screenshot:
cu15-06-export-clicked
- Capture screenshot:
- Action: Open / read the downloaded
calendo-bookings-<date>.csvfile (open it in the browser's downloads viewer or read the file contents). Expect — header row matches EXACTLY:ID,Event Type,Invitee Name,Invitee Email,Phone,Start Time,End Time,Status,Timezone,Location,Notes,Created At,Cancelled At,Cancelled By,Cancellation Reason(15 columns). [L2]
- Action: Search the CSV body for this run's data. Expect: There is a row containing
ravikantguptaofficial+inv-<RUNID>-a@gmail.comAND a row (or rows) containingComma, "Quote" <RUNID>. Because A and B are separate bookings (the CSV is one row PER booking, not per contact), there should be TWO rows with the Alpha email. The "30 Minute Meeting" event type name appears in those rows. [L2]
- Action: Verify CSV escaping of the comma/quote contact. Expect: In the raw CSV text, the comma/quote invitee name appears escaped as
"Comma, ""Quote"" <RUNID>"— i.e. the whole field is double-quote-wrapped and inner quotes are doubled. The commas inside the name must NOT split the field. Confirm the row still has exactly 15 fields. [L2] — This proves CSV injection/escaping is correct; an unescaped comma would corrupt the column alignment.- Capture screenshot:
cu15-07-csv-opened
- Capture screenshot:
- Action: (Range scoping sanity) Change
#exportRangeto All time and click Export CSV (#exportCsvBtn) again; open the new file. Expect: The All-time CSV row count is ≥ the 30-day CSV row count, and it still contains this run's rows. This confirms the range param genuinely scopes the export rather than being ignored. [L2]
- Action: Click the Routing Forms sidebar tab (
.sidebar-nav a[data-tab="routing-forms"]). Expect:#panel-routing-formsbecomes active; below the forms list (#routingFormsList) there is a Conversion Analytics section with container#routingAnalytics. With no routing-form submissions for this run, it shows the text "No routing form submissions yet." (if the account has historical submissions, it instead lists per-form lines of the form "N submissions · M booked · X% conversion"). Confirm the routing analytics surface RENDERS (does not error or stay blank/skeleton). [L1]- Capture screenshot:
cu15-08-routing-analytics
- Capture screenshot:
- Action: Confirm the polls analytics surface. Click the Settings sidebar tab (
.sidebar-nav a[data-tab="settings"]), then the Advanced settings sub-tab (button labelled "Advanced",switchSettingsTab(this,'advanced')). Scroll to the Meeting Polls card. Expect: The polls list container#pollsListrenders — either listing existing polls (each showing a voter count like "N voters" and a Finalize/Copy Link control) or the empty state "No polls yet." The#createPollBtn("+ Create Poll") is present. Confirm the polls surface renders without error. [L1] — Note: the sidebar "Polls" link points to a separate public page/poll/; the host-side poll management and voter-count surface live here under Settings → Advanced.- Capture screenshot:
cu15-09-polls-surface
- Capture screenshot:
L3 reality checks
None — this is a read-mostly reporting suite. The one strong artifact check is the downloaded CSV file (treated as L2 persistence/export evidence in steps 21–24). No invitee emails are sent (Book-on-Behalf entries use plus-aliases of the P1 inbox but this suite does not assert delivery), and no external Google/Outlook Calendar events are created, moved, or deleted. See Pass/Fail.
Cleanup
Leave the host account clean for the next run. Cancel/delete everything created by RUNID:
- Go to the Bookings tab (
.sidebar-nav a[data-tab="bookings"]). - For each of the three seeded bookings (
CU15 Alpha <RUNID>× 2 andComma, "Quote" <RUNID>), open the booking's actions and Cancel it (use the per-row cancel control in#bookingsTableContainer). Confirm each disappears from the active bookings list or shows status "Cancelled". - Re-open the Contacts tab and confirm the run's contacts now show no upcoming meeting (they will still appear as historical contacts — that is expected; contacts are derived from booking history and are not separately deletable. This is acceptable residue; note it in evidence).
- Delete the downloaded CSV files (
calendo-bookings-<date>.csv, both the 30-day and all-time copies) from the local Downloads folder so they don't linger. - No event types, routing forms, polls, or throwaway accounts were created by this suite, so none need deletion.
Pass/Fail criteria
The run PASSES only if ALL of the following are true:
- The dashboard loads in an active P1 session (no login bounce). [precondition]
- All three Book-on-Behalf bookings (A, B, C) were created and appear in the Bookings table. [L2]
- Contacts tab shows exactly ONE aggregated row for the shared Alpha email with Meetings = 2 and a populated Next meeting. [L2]
- Contacts search filters live on both name and email; the All / New (30d) / No-upcoming filters each change the visible set correctly (run contacts present under All and New, absent under No-upcoming). [L1]
- Analytics shows Total Bookings ≥ 3, This Month ≥ 3, valid percentage Cancellation and No-Show rates (0–100%), a sane Most Popular value, a non-empty 30-day volume chart with today's bar ≥ 3, and an Event Type Breakdown row for "30 Minute Meeting" with Bookings ≥ 3. [L2]
- Changing
#exportRangedoes NOT alter on-screen stats (documented quirk); if it does, FAIL/FLAG. [L1] - Export CSV downloads a file named
calendo-bookings-<date>.csvwhose header is EXACTLY the 15 columns listed, that contains two rows for the Alpha email and the comma/quote contact, and where the comma/quote name is correctly escaped as"Comma, ""Quote"" <RUNID>". [L2] - All-time CSV row count ≥ 30-day CSV row count and both contain the run's rows. [L2]
- Routing analytics (
#routingAnalytics) and the polls surface (#pollsListunder Settings → Advanced) both render without error. [L1] - Cleanup completed: all three bookings cancelled and downloaded CSVs removed.
The run FAILS if any metric is obviously wrong (e.g. negative or >100% rates, Total Bookings not reflecting the seeded data), the CSV is missing columns or rows or mis-escapes the comma/quote name, a tab fails to render, or a precondition is unmet.
Evidence to capture
- Screenshots:
cu15-01-bookings-seeded,cu15-02-contacts-alpha-count2,cu15-03-contacts-filters,cu15-04-analytics-stats,cu15-05-analytics-chart-and-breakdown,cu15-06-export-clicked,cu15-07-csv-opened,cu15-08-routing-analytics,cu15-09-polls-surface. - The exact RUNID used.
- The saved CSV file path(s) and the verbatim header line.
- The verbatim CSV line for the comma/quote contact showing the escaping.
- Recorded values:
#statTotal,#statThisMonth,#statCancelRate,#statNoShowRate,#statPopular, and the "30 Minute Meeting" Bookings count from the breakdown table. - A note confirming whether changing
#exportRangechanged on-screen stats (expected: NO).
Manual residue / cannot-verify
- Contacts are not deletable in-UI. Contacts are derived from booking history (
/api/contactsGROUP BY email); cancelling the bookings does not remove the contact rows. The human may want to confirm long-term that historical RUNID contacts don't pollute the account, but there is no in-browser delete action — this is expected behavior, not a bug. - Conversion / no-show data realism. No-show rate only becomes non-zero if a host marks a past booking as no-show; this suite does not advance time or mark no-shows, so it can only verify the metric renders as 0%/valid, not that the no-show computation is correct on real no-show data. Out of scope here (covered by booking-lifecycle suites).
- Routing-form and poll conversion numbers with real submissions. This suite confirms the analytics surfaces render but does not generate public routing-form submissions or poll votes, so the actual conversion-rate math is not exercised end-to-end. TBD / covered by the routing-forms and polls suites.
- CSV
rangeboundary correctness. The suite confirms 30d ⊆ all-time and that the range param is honored directionally, but it cannot precisely verify the 90d/1y cutoffs without bookings spanning those windows (the account would need bookings older than 30/90 days). Left to the human if precise window-boundary correctness must be certified.