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

GET /api/reports/daily-stats

vm_main_campaign_reports (view over call_daily_stats)

Dashboard daily calls summary

GET /api/reports/daily-call-outcomes

Call, grouped by Eastern day

Funnel

GET /api/reports/funnel

main_required_summaries

Sankey disposition flow

GET /api/reports/sankey-chart

main_sankey_chart_view

Daily agent performance

GET /api/reports/daily-agent-performance

vm_main_daily_agent_performance_report (view over agent_daily_performance_stats)

Phone-number pickup rate

GET /api/reports/phone-number-pickup-rate

phone_number_pickup_rate

Email campaign daily engagement

GET /api/reports/email-campaign-daily

v_email_campaign_daily_engagement

Email campaign target summary

GET /api/reports/email-campaign-target-summary

aggregation over email_campaign_targets

AI calling performance

GET /api/reports/ai-calling

BigQuery hubspot4.v_calls_latest joined to hubspot4.v_deals_latest

Re-engagement daily call summary

GET /api/reports/reengagement-daily

BigQuery outreach_app.v_re_engagement_daily_call_summary

Re-engagement contacts

GET /api/reports/reengagement-contacts

BigQuery daily list entries joined to hubspot4.v_calls_latest

Re-engagement results

GET /api/reports/reengagement-results

BigQuery outreach_app.v_re_engagement_results_summary

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

call_daily_stats

vm_main_campaign_reports

lib/supabase/sql/call_daily_stats.sql

agent_daily_performance_stats

vm_main_daily_agent_performance_report

lib/supabase/sql/agent_daily_performance_stats.sql

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

unique_calls

Count of Call rows for the day. Despite the name it does not deduplicate by phone number — see Naming caveats.

total_duration_m

durationMs summed across the day, converted to minutes, rounded to two decimals. NULL durations are ignored by SUM rather than treated as zero.

billed_minutes

Each call’s duration rounded up to a whole minute individually, then summed. This is deliberately not total_duration_m rounded up: 1,000 five-second calls bill 1,000 minutes, not 2. It mirrors per-call carrier billing.

calls_picked_up

Calls whose disposition is not one of Voicemail_Detected, AI_Screening, dial_no_answer, dial_busy, user_declined, invalid_destination, user_hangup, error_retell. Because SQL NOT IN yields NULL for a NULL disposition, calls with no disposition are also excluded. So calls_picked_up + the eight excluded counts + no_disposition equals unique_calls.

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

qualified_lead

Qualified_Lead or Amazon_Seller — the only field that folds two values together

call_back

Callback_Requested

not_qualified

Not_Qualified

not_a_seller

Not_a_Seller

not_interested

Not_Interested

wrong_num

Wrong_Number

voice_mail

Voicemail_Detected

ai_screening

AI_Screening

ivr

IVR

busy

Busy

hangup_early

Hangup_Early

ai_issue

AI_Issue

do_not_call

Do_Not_Call

gatekeeper

Gatekeeper

phone_provider_unavailable

telephony_provider_unavailable

tel_provider_perm_denied

telephony_provider_permission_denied

dial_failed, dial_no_answer, dial_busy, user_declined, user_hangup, invalid_destination, error_retell

Same name as the stored value

no_disposition

NULL

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, or Hangup_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

total_calls

Count of phone_call rows for that day and agent

total_analysis

How many of those calls have a call_ai_agent_analysis_results row. Analysis is 1:1 with Call, so this can never exceed total_calls

avg_score, total_score

Mean and sum of Call.score; calls without a score are ignored, not zero

correct_dispositions

Count of analyses where correctDisposition is true

correct_disposition_pct

Mean of correctDisposition cast to 0/1, times 100. The denominator is analysed calls, not total_calls, so a lightly sampled day can show 100% off a handful of analyses

avg_qa_ai_agent_score, total_qa_ai_agent_score

Mean and sum of the analysis agentScore

total_critical_issues

Number of entries in the analysis issues JSON with priority set to critical, summed. A call with no analysis contributes 0, so a day with no analyses at all reports 0 rather than NULL

hangup_calls

Calls dispositioned Hangup_Early

hangup_disposition_rate_pct

hangup_calls as a percentage of total_calls, two decimals, NULL when the day has no calls

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

total

All calls

valid_conversation

Duration ≥ 10 s, not voicemail, not screened

hubspot_synced

Passed CRM eligibility and was handed off

follow_up_needed

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

subscriptionId, type

Subject

contactId, contactPhone, contactEmail

Context

campaignId, targetId, taskId

Content

subject, body, notes

State

status (sent, failed, delivered, received), direction (default outbound)

External

retellChatId, hubspotContactId, hubspotEngagementId, externalId

Extra

metadata (JSON)

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

/reports

Report index

/reports/daily-stats, /funnel, /sankey-chart, /daily-agent-performance, /phone-number-pickup-rate, /email-campaign-daily, /reengagement-daily, /reengagement-results, /ai-calling

Individual reports

/activity

Unified activity feed

/agents/analysis, /agents/issues

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.