Appearance
Customer-facing call-count inconsistency across dashboard widgets
A single Avoca enterprise calls page shows the same window's call activity through three widgets with three different totals, with no UI signal that the definitions differ. Customers see numbers that don't add up and cannot trace why.
This is not a footgun. It is a real product bug. It is customer-facing.
What a customer sees
On /enterprise/[id]/calls for the same date range (Mon 5/18 – Sun 5/24 for EAS), three widgets disagree:
| Widget | Number | What it counts |
|---|---|---|
| Total Calls stat card (top) | 36 | display=true AND parent_call_id IS NULL AND isActionableSql (non-actionable per team kpi_configs excluded) |
| Call Flow / Sankey total | 53 | Same as above but BEFORE the actionable filter AND including the Avoca-Human child legs separately (different parent_call_id treatment) |
| Calls table (bottom) | 47 | display=true AND parent_call_id IS NULL (no actionable filter) |
| Per-team Performance table | 36 (sum) | Same as the stat card |
A customer who clicks the "Send weekly report" button gets the email's "Total Calls" row, which is yet another definition (47 today; this PR brings it to 36 to match the stat card). The numbers were never wrong in a SQL sense; they were wrong in a product sense because the dashboard doesn't tell you which definition each widget uses.
The two layers of filtering
There are two filters in play, and any given widget applies some subset.
Layer 1: structural
Three structural rules every reasonable count should satisfy:
calls.display = true(excludes test-phone-line calls and Hamming simulations at ingest)calls.parent_call_id IS NULL(counts each phone call once, not separately for the AI leg + the Avoca-Human child leg)- Avoca-Human coalesce join (when a child leg exists, its
is_booked / is_bookable / durationfold into the parent row's metrics)
The Sankey total intentionally does NOT apply parent_call_id IS NULL. The Sankey funnels child legs through the transferred branch on purpose, so its 53 differs from the calls table's 47 by exactly the count of Avoca-Human child legs that week.
Layer 2: actionable
The dashboard's stat card and the Sankey's actionable buckets ALSO apply a per-team "actionable" filter:
NOT (
call_reason ∈ team's na_reasons
OR call_outcome ∈ team's na_outcomes
OR caller_id ∈ team's na_phones
)A call is non-actionable when it matches one of those three lists. The lists come from kpi_configs.non_actionable_* per team. Teams with no kpi_configs row fall back to defaults baked into @avoca/api-contracts:
Verified observations
DEFAULT_NON_ACTIONABLE_CALL_REASONS:EXCUSED_TEST_CALL,EXCUSED_TELEMARKETING,EXCUSED_SPAM,EXCUSED_SILENT_CALLDEFAULT_NON_ACTIONABLE_CALL_OUTCOMES:EXCUSED-TEST_CALL,EXCUSED-TELEMARKETING,EXCUSED-SPAM,EXCUSED-SILENT_CALL,EXCUSED-UNRESPONSIVE,EXCUSED-WRONG_NUMBER,OUTBOUND-NOT_PICKED_UP,OUTBOUND
EAS has no kpi_configs row, so EAS shops use those defaults. For Mon 5/18 – Sun 5/24, 11 calls had a call_reason in that default list, so the actionable count is 47 - 11 = 36.
Why this is a bug, not a foot gun
A foot gun is a tool that an informed operator can use safely. This isn't that. The problems are:
- No widget tells the user which filter it applies. A "Total Calls" label appears on the stat card, in the Sankey diagram, in the per-shop table, and in the email subject. Each one means a different thing. There is no tooltip, footnote, or info icon that surfaces this.
- Defaults are hardcoded in a TypeScript file. Changing them requires a code deploy. A customer can't even see what the defaults are, much less change them.
- Per-team overrides exist but aren't surfaced. The
kpi_configstable powers the override but has no admin UI for the non-actionable lists. A customer has no way to know that their shop has (or doesn't have) a row, what's in it, or that the dashboard's "Total Calls" depends on it. - The two layers compound silently. A customer comparing the Sankey's 53 against the table's 47 sees them differ by 6 (the Avoca-Human child legs in this case). They can't tell whether that's a structural difference (legs vs phone calls) or a filter difference (actionable vs raw) because the dashboard doesn't tell them.
- The customer-facing weekly email lands on yet another number unless the report code happens to mirror exactly one of the dashboard's definitions. Today's email matches the calls table (47); after our PR #10831 it'll match the stat card (36). Either way the report could disagree with at least one widget the customer can see.
Unsolicited Opinion
The right fix has two parts:
Documentation, soon. Every widget that shows a call count should expose its definition on hover or via an info icon. Three sentences each: "Counts inbound calls answered, excluding spam/silent/test/wrong-number/etc.", "Counts every leg including AI-to-human transfers", etc. Make the definitions self-service to verify.
Product, eventually. Pick one canonical definition for "Total Calls" and unify around it. The Sankey's value-add isn't in showing a different total; it's in showing the funnel. The funnel should start from the same number the stat card shows.
Customer-facing reporting on inconsistent definitions is a credibility-destroying bug. Every "huh, that doesn't match" moment costs trust.
Blast radius for Lazer-shipped work
- Any reporting we add (the EAS enterprise weekly report, future custom rollups) has to pick which dashboard definition to mirror. Today we mirror the stat card via per-team kpi_configs lookup, which means we get the actionable filter for free (including the hardcoded defaults). See PR #10831.
- If Avoca ever changes the default lists in
@avoca/api-contracts, our report numbers shift silently in the next deploy. We share that risk with every dashboard widget that uses the same defaults. - A customer who configures
kpi_configs.non_actionable_*for their team automatically gets the new lists in both the dashboard and our report, because both read fromgetKPISettingsForTeams. That part works.
Open questions
- Is there an existing tracking issue inside Avoca for unifying widget definitions? Worth asking before we add ours.
- Does the Sankey component intentionally count child legs (because the funnel needs them) or by oversight? Either way the totals-disagree problem is real.
- Is the customer-facing report supposed to match the calls table (raw) or the stat card (actionable)? Different consumers want different things; we should ask.
Status
Reported here, not yet escalated to Avoca. This page is the running log of what we found so we have something concrete to reference when we raise it.