Skip to content

Dashboard — Call Pickup Time

The Pickup Time card on /dashboard/<orgId>/overview shows the average seconds from queue entry to agent pickup for answered inbound queue calls in the selected time window. Hospitals use it to gauge how quickly their agents respond to patient calls.

Status: shipped 2026-05-21 via PRs #267 / #269 / #271 / #272 (promoted to main via #273).

Pickup Time is one of four range-scoped cards (Total Calls, Pickup Time, Open Tickets [live], Avg Duration). The time selector at the page header drives all four cards + the Call Volume chart so the visible scope is always one consistent window.

What it measures

For each answered inbound queue call:

  • Parent CDR (PJSIP/<trunk> → Queue(...)): duration = billsec because the dialplan calls Answer() immediately for queue MOH. There's no ring-time signal in the parent row.
  • Answered queue child leg (Local/qm…@<org>__qmem → PJSIP/<agent>): duration = ring + talk and billsec = talk. So child.duration - child.billsec = the agent's actual ring seconds before pickup.

For RingAll queues (the hospital default) this equals the caller's wait time exactly. It matches what Asterisk announces to the agent on connect: "Hold time: N seconds."

The SQL

In api/src/server.js/api/v1/calls/stats:

SELECT
  ROUND(AVG(child.duration - child.billsec), 1) AS avg_pickup_secs,
  COUNT(*)                                       AS sample_calls
FROM asterisk_cdr parent
JOIN asterisk_cdr child
  ON child.linkedid = parent.linkedid
 AND child.id != parent.id
WHERE (parent.accountcode = ? OR parent.peeraccount = ? OR parent.channel LIKE ?)
  AND parent.dcontext LIKE '%incoming%'
  AND parent.disposition = 'ANSWERED' AND parent.billsec > 0
  AND child.disposition  = 'ANSWERED' AND child.billsec  > 0
  AND child.dstchannel LIKE 'PJSIP/%'
  AND child.channel    LIKE 'Local/qm%qmem%'
  AND parent.calldate >= DATE_SUB(CURDATE(), INTERVAL <days> DAY);

Required signals (in order of restrictiveness):

  • Parent must be inbound (dcontext LIKE '%incoming%') and ANSWERED with billsec > 0 — excludes IVR-only calls that never reached an agent.
  • Child must be ANSWERED with billsec > 0 — picks the agent leg that actually had talk time, not the parallel agents who only rang then gave up.
  • Child channel is the Local queue-member dial-out (Local/qm…@<org>__qmem), and child dstchannel is the agent's PJSIP endpoint — rules out non-queue Local channels.
  • Time window filters on the parent's calldate (the inbound channel timestamp).

The same accountcode/peeraccount/channel-prefix filter the rest of /api/v1/calls/stats uses isolates per-org.

Accuracy by queue strategy

Strategy Pickup Time accuracy Notes
ringall ✅ Exact Every agent rings simultaneously. Answered child's ring time = caller's holdtime. Matches Hold time: N seconds announcement.
leastrecent / fewestcalls / random ✅ Exact Single agent rings at a time; the answered child's ring time = total caller wait.
sequential / linear ⚠️ Under-counts Multiple agents ring sequentially. Each agent's ring is a separate child row. We pick the answered child (the last one), but the caller actually waited through all the prior rings. The metric still bounds pickup-time from below — so a "30s pickup" display means at least 30s, possibly more. The framing "X or worse" remains sound.
rrmemory / rrordered ✅ Approximately exact Similar to ringall in that the typical agent rotation rings one or a few; in practice indistinguishable from ringall for the avg metric.

Current prod hospital orgs all use ringall. If a customer switches to a sequential strategy, the under-count caveat applies and we should revisit (see #270 for the queue_log realtime path that would fix this).

Three-tier badge

Thresholds set 2026-05-21 by Operations:

Pickup avg Tier Badge variant Subtitle
≤ 20s Healthy outline "Agents are responsive"
20 – 30s Moderate secondary "Acceptable — worth watching"
> 30s Needs intervention destructive (red) "Operationally too slow — escalate"
no answered calls in window (badge: "no data") outline "No answered queue calls yet"

Wording note: "Moderate" is deliberately used instead of "Average" — the card already displays an average value, so "Average" as a tier label reads ambiguously. Hovering the subtitle row shows the thresholds in a tooltip so an operator who sees "Moderate" can hover and immediately know the band.

Default time window

Pages land on Last 7 days by default (PR #272). Operator dashboards prioritise this-week operational health over 90-day historical trends. The page-level toggle lets operators expand to 30d or 90d when they want broader context. All four cards + the Call Volume chart respect whichever window is selected.

Why this approach and not queue_log realtime

queue_log is Asterisk's own per-event log (ENTERQUEUE / CONNECT / ABANDON / COMPLETE*). The CONNECT.data1 field is the canonical holdtime — strictly correct for every queue strategy. We could enable realtime ingestion via:

; /etc/asterisk/extconfig.conf
[settings]
queue_log => odbc,asterisk

But on Asterisk 20.18 (tested 2026-05-21 on staging) this mapping alone is not sufficient — app_queue.so doesn't pick up the realtime mapping even after module unload + module load. The file /var/log/asterisk/queue_log keeps receiving events but the table stays empty. Needs more investigation; tracked in #270.

The CDR child-leg approach used here:

  • ✅ Works on existing data (no Asterisk config change)
  • ✅ No ingestion gap — historical calls have the metric immediately
  • ✅ Same SQL engine as the rest of the stats endpoint (no new connection, no new pool)
  • ⚠️ Under-counts for sequential queues (acceptable today; not used by current customers)

When #270 resolves and queue_log realtime ingestion is flowing, the metric can switch to AVG(CAST(data1 AS UNSIGNED)) WHERE event = 'CONNECT' against the queue_log table for strict correctness across all strategies.

Verified on prod CDR (last 90 days at promotion time)

345 answered queue calls
avg 11.1s · min 4s · max 51s

All plausible hospital pickup times. Spot-checked 15 individual rows — all between 4-31s, no negatives, no NULLs, no outliers.

  • Concurrent Call Cap Architecture — sister dashboard work shipped same week (org + trunk caps, CDR cap_rejected userfield, call-logs badge).
  • Queue Architecture — broader queue dialplan generation reference.
  • Issue #270 — queue_log realtime activation gap. Unblocks sequential-queue correctness when resolved.
  • PR #271 — the Pickup Time card. PR body documents the CDR-child-leg SQL discovery + verification.
  • PR #272 — three-tier thresholds + 7d default.
  • Issue #259 — API reference docs auto-gen (Scalar/Mintlify) for partner integrations. Unrelated to Pickup Time but the partner-docs roadmap.