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

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:

Expected derived values:

Steps

  1. Action: Go to the dashboard (https://calendo.dev/dashboard/). Expect: The app shell loads (#appLayout visible, #welcomeHeader visible) and the left sidebar shows tabs including Contacts, Analytics, and Routing Forms. [L1]
  1. Action: Click the Bookings sidebar tab (.sidebar-nav a[data-tab="bookings"]). Expect: The Bookings panel becomes active (#panel-bookings has class active) and a bookings table container (#bookingsTableContainer) is shown. [L1]
  1. Action: Click + Book on Behalf (#bookOnBehalfBtn). Expect: The inline form #bookOnBehalfForm becomes visible with an event-type dropdown (#bobEventType), a datetime field (#bobStartTime), name (#bobName) and email (#bobEmail) fields, and a Create Booking button (#bobSubmitBtn). [L1]
  1. Action: Seed Booking A — in #bobEventType select "30 Minute Meeting"; set #bobStartTime to 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 contain CU15 Alpha <RUNID>. [L2]
  1. 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.
  1. 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: #bookingsTableContainer contains Comma, "Quote" <RUNID>. [L2] (This row exercises CSV comma/quote escaping later.)
    • Capture screenshot: cu15-01-bookings-seeded
  1. Action: Click the Contacts sidebar tab (.sidebar-nav a[data-tab="contacts"]). Expect: #panel-contacts becomes active; the header reads "Contacts / Everyone who has booked a meeting with you."; the filter bar #contactsFilterBar shows 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 #contactsTableContainer with columns Name, Email, Phone, Last meeting, Next meeting, Meetings. [L1]
  1. 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 shows CU15 Alpha <RUNID>, and its Meetings column shows 2. 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
  1. 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]
  1. Action: Clear the search, then type the Alpha email fragment inv-<RUNID>-c into #contactsSearch. Expect: The table now shows only the Comma, "Quote" <RUNID> contact, proving search matches on email as well as name. [L1]
  1. Action: Clear #contactsSearch completely. Click the New (30d) filter ([data-contacts-filter="new"]). Expect: The button gets the active class; the table shows contacts whose FIRST booking is within the last 30 days — both this run's contacts qualify (they were just created), so both CU15 Alpha <RUNID> and Comma, "Quote" <RUNID> remain visible. [L1]
  1. Action: Click the No upcoming filter ([data-contacts-filter="inactive"]). Expect: The table now shows ONLY contacts with no future/confirmed meeting (next_meeting empty). This run's contacts all have FUTURE bookings, so neither CU15 Alpha <RUNID> nor Comma, "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 to next_meeting.
    • Capture screenshot: cu15-03-contacts-filters
  1. 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]
  1. Action: Click the Analytics sidebar tab (.sidebar-nav a[data-tab="analytics"]). Expect: #panel-analytics becomes active. The stats grid #analyticsStats shows 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]
  1. 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
  1. Action: Read Cancellation Rate (#statCancelRate) and No-Show Rate (#statNoShowRate). Expect: Both are percentages of the form N% (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]
  1. 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]
  1. 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 on created_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
  1. Action: Verify the range selector's scope. Note the on-screen #statTotal value. 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/analytics always 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.
  1. Action: Set #exportRange to Last 30 days, then click Export CSV (#exportCsvBtn). Expect: A file download begins; the browser saves a file named calendo-bookings-<YYYY-MM-DD>.csv (today's date). The agent must capture the saved file path. [L2]
    • Capture screenshot: cu15-06-export-clicked
  1. Action: Open / read the downloaded calendo-bookings-<date>.csv file (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]
  1. Action: Search the CSV body for this run's data. Expect: There is a row containing ravikantguptaofficial+inv-<RUNID>-a@gmail.com AND a row (or rows) containing Comma, "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]
  1. 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
  1. Action: (Range scoping sanity) Change #exportRange to 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]
  1. Action: Click the Routing Forms sidebar tab (.sidebar-nav a[data-tab="routing-forms"]). Expect: #panel-routing-forms becomes 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
  1. 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 #pollsList renders — 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

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:

  1. Go to the Bookings tab (.sidebar-nav a[data-tab="bookings"]).
  2. For each of the three seeded bookings (CU15 Alpha <RUNID> × 2 and Comma, "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".
  3. 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).
  4. 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.
  5. 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 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

Manual residue / cannot-verify