Reporting and activity
Most reports are served from PostgreSQL views rather than computed in application code, so a report
is a thin route over a query. The re-engagement daily call summary and results reports read
BigQuery instead. All date filters are anchored to Eastern Time via parseDateInNY or US-Eastern
YYYY-MM-DD strings.
Reports and their sources
| Report | Endpoint | Source view |
|---|---|---|
Daily call stats |
|
|
Dashboard daily calls summary |
|
|
Funnel |
|
|
Sankey disposition flow |
|
|
Daily agent performance |
|
|
Phone-number pickup rate |
|
|
Email campaign daily engagement |
|
|
Email campaign target summary |
|
aggregation over |
AI calling performance |
|
BigQuery |
Re-engagement daily call summary |
|
BigQuery |
Re-engagement contacts |
|
BigQuery daily list entries joined to |
Re-engagement results |
|
BigQuery |
Most views prefixed vm_ are materialised. If a report looks stale, check the refresh schedule for
its view before suspecting the route. The two exceptions are vm_main_campaign_reports and
vm_main_daily_agent_performance_report, which keep the vm_ name for compatibility but are now
plain views over incrementally maintained rollup tables.
Daily report rollups
The daily call stats and daily agent performance reports are backed by rollup tables rather than materialized views:
| Rollup table | Exposed as | Script |
|---|---|---|
|
|
|
|
|
|
A full REFRESH MATERIALIZED VIEW re-aggregates every call ever made, so its cost grows with
history. At roughly 10–13k calls a day that became untenable: both refresh jobs began failing on the
120 s statement_timeout and the campaign report silently froze for a month.
Instead, refresh_call_daily_stats(p_from, p_to) and
refresh_agent_daily_performance_stats(p_from, p_to) recompute one inclusive America/New_York date
range, deleting and reinserting those days in a single transaction so readers never see a partial
window. pg_cron calls them every 10 minutes for the trailing 3 days, plus a nightly 30-day
re-sync. Both windows are fixed, so refresh cost does not grow with history.
A 3-day hot window is safe because a Call row stops being updated within about 21 hours of
creation. Anything later is caught by the nightly re-sync.
Both refreshes depend on idx_call_created_at. Without an index on "createdAt" alone the planner
falls back to a composite index where createdAt is the third column and scans all of it, which
makes a windowed aggregate slower than scanning the whole table.
refreshed_at on each table records when a day was last recomputed. To rebuild history — after
changing an aggregate expression, for example — call the refresh function directly over the range,
chunked to stay inside the statement timeout.
How the daily metrics are derived
The definitions below are the contract these two reports actually implement. They are recorded here
because several are not what the column name suggests, and a reader comparing two reports — or
reconciling a number against Call by hand — will otherwise conclude the data is wrong.
Shared conventions
Date bucketing. Call.createdAt is a timestamp without time zone holding UTC. Every report
buckets by Eastern calendar day, which is why the expression converts twice: first tagging the value
as UTC, then reading it in America/New_York. A call at 02:00 UTC belongs to the previous Eastern
day. The conversion is DST-aware, so the UTC offset is −4 or −5 depending on the date.
The two reports count different populations. Daily call stats includes rows where channelType is
either phone_call or NULL; daily agent performance includes only phone_call. Measured against
the full table, that is 993,540 calls versus 993,157 — the 383 rows with a NULL channelType appear
in one report and not the other. The totals are therefore not expected to reconcile, and the gap is
not a bug.
Daily call stats volume fields
| Field | Derivation |
|---|---|
|
Count of |
|
|
|
Each call’s duration rounded up to a whole minute individually, then summed.
This is deliberately not |
|
Calls whose disposition is not one of |
Daily call stats disposition fields
Each is a straight count of calls carrying that Call.disposition value. Most column names differ
from the stored value, so the mapping matters when reconciling against the table:
| Field | Call.disposition value |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Same name as the stored value |
|
|
Dispositions are free-text, not a database enum, and the table above is not exhaustive of what is
actually stored. Around 2,400 calls carry one of roughly 17 other values — marked_as_spam (the
largest at ~1,500), No_Response, sip_routing_error, Hang Up, error_asr, and several
title-case variants such as Confirmed Amazon Seller that predate the current naming.
Two consequences. The disposition columns do not sum to unique_calls. And because those values are
not in the exclusion list, they all land in calls_picked_up — so that figure currently includes
~1,500 spam-flagged calls and a few hundred other non-answers. Treat it as an upper bound.
Adding a column, or extending the exclusion list, means editing the aggregate in
lib/supabase/sql/call_daily_stats.sql and re-running the refresh function over the affected history
to restate it.
Dashboard daily calls summary
The home-page Daily Calls Summary chart does not read calls_picked_up. It queries Call for the
selected range (GET /api/reports/daily-call-outcomes) so the three series can use duration.
The population matches daily call stats: channelType is phone_call or NULL, bucketed by Eastern
day. Each bar stacks three exclusive parts that add up to total calls:
-
Conversations — connects whose disposition is not
Do_Not_Call,Wrong_Number, orHangup_Early. -
Other connects — the rest of the connects. These are Do Not Call, wrong number, and hang up early.
-
Not connected — every other call that day.
A connect is a call with durationSec greater than 0, inVoicemail not true, and a disposition other
than Voicemail_Detected or AI_Screening. The percent above each bar is connects divided by total
calls. Days with no calls are still returned so the axis stays continuous.
Daily agent performance fields
One row per Eastern day and Call.agentId, with agent_name resolved through ai_agents. Rows
with no agent collapse into a single per-day row rather than being dropped.
| Field | Derivation |
|---|---|
|
Count of |
|
How many of those calls have a |
|
Mean and sum of |
|
Count of analyses where |
|
Mean of |
|
Mean and sum of the analysis |
|
Number of entries in the analysis |
|
Calls dispositioned |
|
|
Naming caveats
unique_calls counts calls, not unique phone numbers. The rollup’s internal naming still carries
traces of an earlier intent — deduplicating repeated calls to the same number per day and keeping the
latest disposition — but the grouping key is the call’s primary key, so no deduplication happens.
A number dialled three times in one day contributes three to unique_calls. Read the column as
"calls that day".
Changing this would move every historical figure in the report, so it is a product decision rather than a cleanup. Until that decision is taken, the name is misleading but the behaviour is stable.
Daily call stats
One row per date plus a totals object, both validated by DailyStatSchema in
lib/schema/reports.ts.
Volume: unique_calls, total_duration_m, billed_minutes, calls_picked_up.
Per-disposition counts: qualified_lead, call_back, not_qualified, not_a_seller,
not_interested, wrong_num, voice_mail, ai_screening, busy, hangup_early, ai_issue,
do_not_call, ivr, gatekeeper, dial_failed, phone_provider_unavailable, dial_no_answer,
user_declined, invalid_destination, dial_busy, user_hangup, error_retell,
tel_provider_perm_denied, no_disposition.
Returning a totals object alongside the daily rows means the UI never re-sums the page and
therefore cannot disagree with the database.
How each of these is computed — including the per-call rounding behind billed_minutes and what
calls_picked_up excludes — is in How the daily metrics are derived.
Funnel
Four stages, returned both daily and aggregated:
| Stage | Meaning |
|---|---|
|
All calls |
|
Duration ≥ 10 s, not voicemail, not screened |
|
Passed CRM eligibility and was handed off |
|
Requires a human next step |
Sankey
main_sankey_chart_view produces the node/link graph of how calls flow from total volume through
conversation validity and CRM status into individual dispositions. The response has a daily array
and an aggregated object, each { nodes: [{ name }], links: [{ source, target, value }] } where
source and target are node indices.
SANKEY_NODE_COLOR_MAP in lib/constants.ts assigns a stable colour per node name, grouped by
meaning: blue for entry, green for positive outcomes, red shades for negative, indigo and purple for
call-status problems, teal for AI-related, grey for technical failures, amber for follow-up. Keeping
the map in constants rather than the chart component means the same disposition is the same colour
in every visualisation.
Daily agent performance
Per date and per AI agent — the report that makes prompt versions comparable:
total_calls, total_analysis, avg_score, total_score, correct_dispositions,
correct_disposition_pct, avg_qa_ai_agent_score, total_qa_ai_agent_score,
total_critical_issues, hangup_calls, hangup_disposition_rate_pct.
Accepts date, or dateFrom and dateTo.
See How the daily metrics are derived for how each field is computed, and why this report’s call total does not match the daily call stats report.
Phone-number pickup rate
Per-number pickup rate, filterable by state and provider. This is the input to keep, rest or
release decisions — see Phone numbers.
Email reports
-
Daily engagement — sends, opens, clicks and bounces per day, optionally scoped to one
campaignId. -
Target summary — recipient status distribution over a date range.
AI calling performance
The Calls total comes from vm_main_campaign_reports (GET /api/reports/daily-stats), the same Outreach call log as the dashboard Daily Calls card. Connect rate is calls longer than 0 seconds that are not voicemail or AI screening, divided by that Calls total. Conversation rate is calls longer than 10 seconds that are also not a hang-up (Hangup_Early or user_hangup), a gatekeeper, or a wrong number, divided by the same total. HubSpot does not receive every AI call. Outcome stages still come from BigQuery hubspot4.v_calls_latest where ownerid is 86817215. The Outcome funnel Conversations bar is distinct people with a mapped conversation disposition, shown as a share of contacts reached. It does not use the call-level Conversation rate rules.
Deals come from hubspot4.v_deals_latest, which keeps one row per Stitch-synced deal. The query
does not read the raw hubspot4.deals table. Funded status comes from
PayabilitySheets.v_supplier_summary_extended.first_purchase_date.
The app applies dateFrom / dateTo (YYYY-MM-DD) with a 366-day cap. Missing dates default to the
last 30 Eastern days. A deal counts only when it was created on or after that contact’s first AI
call in the range. Lost reasons group those lost deals by lost_reason, falling back to
closed_lost_reason, with a blank reason shown as No reason. Deal won is the best deal stage closedwon (Active (Won)) or 1045206663
(Closed Won). Funded is a first purchase on or after that first AI call. Amazon qualified is the
contact’s current property_ai_call_outcome of Amazon_Seller, not the status at call time.
The warm-leads list (/reports/ai-calling/warm-leads) shows, per contact, human calls (HubSpot
v_calls_latest with an owner other than 86817215) and emails (hubspot4.v_emails_latest, any
owner). Both are counted from the contact’s first Warm Lead call in the range up to today; contacts
listed only for a won deal use their first AI call instead. Engagements are credited to the first
associated contact only. Each row links to the contact in HubSpot.
UI: /reports/ai-calling, linked from /reports. Hook: useAiCallingReport.
Re-engagement daily call summary
Source is the BigQuery view outreach_app.v_re_engagement_daily_call_summary (documented in
lib/bigquery/v_re_engagement_daily_call_summary.sql). The view is global — there is no
subscriptionId column — so the API authenticates the session but does not tenant-filter the
query.
The app applies dateFrom / dateTo (YYYY-MM-DD, list dates) with a 90-day cap. Missing dates
default to the last 14 Eastern days. The service sums daily into by_segment (including per-
disposition call counts) and a funnel (total_contacts, called_contacts,
uncalled_contacts, never_called_contacts, connected_contacts, positive_calls_contacts).
The segment chart stacks those HubSpot disposition counts. Funnel stage 3 is distinct listed
contacts with at least one mapped conversation disposition. positive_calls_contacts is Warm
Lead, Follow up scheduled, API Connected Confirmed, or Account Created. The header and daily chart count re-engagement entries (one list date each). contact_funnel counts each person once: people, dialled, conversations, positive outcomes, and attributed wins (first purchase on or after their first list date), plus not dialled and never called. The contact list at /reports/reengagement-contacts shows those people and their list history.
UI: /reports/reengagement-daily, linked from /reports and the Call summary button on
/reengagement. Hook: useReengagementDailyReport.
Re-engagement results
Source is the BigQuery view outreach_app.v_re_engagement_results_summary (documented in
lib/bigquery/outreach_app/v_re_engagement_results_summary.sql). One view row is one HubSpot
contact that appeared on a daily list, joined to the latest supplier statuses. The view is
global — there is no subscriptionId column — so the API authenticates the session but does not
tenant-filter the query.
The app does not pull contact-grain rows. queryReengagementResultsSummary() runs a single
COUNT(*) / COUNTIF over the view (all-time; no date filter). Status matching is
LOWER(TRIM(…)). API needed includes both needed and required. Payability includes
suspended as a first-class bucket. Each group also returns other and null. no_supplier
is supplierkey IS NULL. First-purchase wins: with_first_purchase is
first_purchase_date IS NOT NULL; attributed_wins is
DATE(first_purchase_date) >= DATE(first_listdate) (purchase on or after the contact’s first
daily-list appearance).
UI: /reports/reengagement-results, linked from /reports and the Results button on
/reengagement. Hook: useReengagementResultsReport.
Activity feed
Activity is the unified interaction log. ActivityType covers sms, email, call, task,
meeting, note, reminder.
| Field group | Fields |
|---|---|
Scope |
|
Subject |
|
Context |
|
Content |
|
State |
|
External |
|
Extra |
|
externalId is unique per (subscriptionId, externalId), which makes webhook-driven activity
upserts idempotent.
GET /api/activity merges Activity rows with Call rows into one chronological feed, filterable
by type or types, search, campaignId, dateFrom, dateTo, with standard pagination.
lib/utils/map-activity-feed.ts normalises both sources into one shape, and
lib/utils/resolve-activity-campaign-names.ts resolves campaign names in bulk rather than per row.
Calls are merged from their own table rather than duplicated into Activity because Call carries
far more structure (transcript, recording, analysis) than an activity row could hold.
Hooks: useActivity(params?) in lib/hooks/use-activity.ts. Repository:
lib/repositories/activity-repository.ts.
Dashboards
| Route | Contents |
|---|---|
|
Overview — recent calls, daily stats summary |
|
Report index |
|
Individual reports |
|
Unified activity feed |
|
Call-analysis results and issue aggregation |
Chart components live in components/reports/. Hooks: use-daily-stats, use-yearly-stats,
use-daily-calls, use-recent-calls, use-daily-agent-performance-chart,
use-email-campaign-target-summary, use-reengagement-daily-report, use-ai-calling-report,
use-reengagement-results-report.