Learnixo

Amazon Connect & AI Contact Centers · Lesson 5 of 6

Real-Time Call Analytics Dashboard

What We're Building

A real-time dashboard for call center supervisors showing:

  • Live queue metrics — calls waiting, agents available, oldest call
  • Today's volume — calls handled, abandoned, avg handle time, service level
  • Agent status board — who's on a call, available, in break
  • Historical trend charts — hourly call volume, quality scores over time
  • Recent calls feed — latest calls with quality scores and sentiment

Architecture

Amazon Connect Metrics API (polling every 30s)
    ↓
API Gateway → Lambda → DynamoDB cache
    ↓
React dashboard polls every 30s

DynamoDB Streams (new transcripts/scores)
    ↓
Lambda → API Gateway WebSocket
    ↓
React dashboard receives push updates

Backend: Metrics Lambda

Python
# lambda/dashboard_metrics/handler.py

import boto3
import json
import os
from datetime import datetime, timedelta
from decimal import Decimal

connect = boto3.client("amazon-connect")
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(os.environ["TABLE_NAME"])

INSTANCE_ID = os.environ["CONNECT_INSTANCE_ID"]


def handler(event, context):
    path = event["rawPath"]
    user = event["requestContext"]["authorizer"]["jwt"]["claims"]
    clinic_id = user.get("custom:clinic_id")
    
    if path == "/dashboard/realtime":
        return get_realtime_metrics(clinic_id)
    elif path == "/dashboard/today":
        return get_today_metrics(clinic_id)
    elif path == "/dashboard/agents":
        return get_agent_status(clinic_id)
    elif path == "/dashboard/recent-calls":
        return get_recent_calls(clinic_id)
    
    return {"statusCode": 404, "body": '{"error": "not found"}'}


def get_realtime_metrics(clinic_id: str) -> dict:
    queue_ids = get_clinic_queue_ids(clinic_id)
    
    if not queue_ids:
        return response(200, {"queues": []})
    
    data = connect.get_current_metric_data(
        InstanceId=INSTANCE_ID,
        Filters={"Queues": queue_ids, "Channels": ["VOICE"]},
        Groupings=["QUEUE"],
        CurrentMetrics=[
            {"Name": "CONTACTS_IN_QUEUE",   "Unit": "COUNT"},
            {"Name": "OLDEST_CONTACT_AGE",  "Unit": "SECONDS"},
            {"Name": "AGENTS_AVAILABLE",    "Unit": "COUNT"},
            {"Name": "AGENTS_ON_CALL",      "Unit": "COUNT"},
            {"Name": "AGENTS_STAFFED",      "Unit": "COUNT"},
        ]
    )
    
    queues = []
    for collection in data["MetricResults"]:
        q = collection["Dimensions"]["Queue"]
        metrics = {m["Metric"]["Name"]: m.get("Value", 0) for m in collection["Metrics"]}
        queues.append({
            "queue_id": q["Id"],
            "queue_name": q["Arn"].split("/")[-1],
            "contacts_waiting": int(metrics.get("CONTACTS_IN_QUEUE", 0)),
            "oldest_wait_seconds": int(metrics.get("OLDEST_CONTACT_AGE", 0)),
            "agents_available": int(metrics.get("AGENTS_AVAILABLE", 0)),
            "agents_on_call": int(metrics.get("AGENTS_ON_CALL", 0)),
            "agents_staffed": int(metrics.get("AGENTS_STAFFED", 0)),
        })
    
    return response(200, {"queues": queues, "updated_at": datetime.utcnow().isoformat()})


def get_today_metrics(clinic_id: str) -> dict:
    today = datetime.utcnow().strftime("%Y-%m-%d")
    start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
    end = datetime.utcnow()
    
    queue_ids = get_clinic_queue_ids(clinic_id)
    
    data = connect.get_metric_data(
        InstanceId=INSTANCE_ID,
        StartTime=start,
        EndTime=end,
        Filters={"Queues": queue_ids, "Channels": ["VOICE"]},
        Groupings=["QUEUE"],
        HistoricalMetrics=[
            {"Name": "CONTACTS_HANDLED",   "Unit": "COUNT",   "Statistic": "SUM"},
            {"Name": "CONTACTS_ABANDONED",  "Unit": "COUNT",   "Statistic": "SUM"},
            {"Name": "HANDLE_TIME",         "Unit": "SECONDS", "Statistic": "AVG"},
            {"Name": "QUEUE_ANSWER_TIME",   "Unit": "SECONDS", "Statistic": "AVG"},
            {"Name": "SERVICE_LEVEL",       "Unit": "PERCENT", "Statistic": "AVG",
             "Threshold": {"Comparison": "LT", "ThresholdValue": 20}},
        ]
    )
    
    totals = {"handled": 0, "abandoned": 0, "avg_handle": [], "avg_wait": [], "service_level": []}
    
    for collection in data["MetricResults"]:
        metrics = {m["Metric"]["Name"]: m.get("Value", 0) for m in collection["Collections"]}
        totals["handled"] += metrics.get("CONTACTS_HANDLED", 0)
        totals["abandoned"] += metrics.get("CONTACTS_ABANDONED", 0)
        if metrics.get("HANDLE_TIME"): totals["avg_handle"].append(metrics["HANDLE_TIME"])
        if metrics.get("QUEUE_ANSWER_TIME"): totals["avg_wait"].append(metrics["QUEUE_ANSWER_TIME"])
        if metrics.get("SERVICE_LEVEL"): totals["service_level"].append(metrics["SERVICE_LEVEL"])
    
    return response(200, {
        "date": today,
        "calls_handled": int(totals["handled"]),
        "calls_abandoned": int(totals["abandoned"]),
        "abandonment_rate": round(
            totals["abandoned"] / max(totals["handled"] + totals["abandoned"], 1) * 100, 1
        ),
        "avg_handle_time": round(sum(totals["avg_handle"]) / max(len(totals["avg_handle"]), 1), 0),
        "avg_wait_time": round(sum(totals["avg_wait"]) / max(len(totals["avg_wait"]), 1), 0),
        "service_level_pct": round(sum(totals["service_level"]) / max(len(totals["service_level"]), 1), 1),
    })

React Dashboard

Custom Hook: useRealtimeMetrics

TYPESCRIPT
// src/hooks/useDashboardMetrics.ts
import { useQuery } from "@tanstack/react-query";
import { get } from "aws-amplify/api";

interface QueueMetric {
  queue_id: string;
  queue_name: string;
  contacts_waiting: number;
  oldest_wait_seconds: number;
  agents_available: number;
  agents_on_call: number;
}

interface TodayMetrics {
  calls_handled: number;
  calls_abandoned: number;
  abandonment_rate: number;
  avg_handle_time: number;
  avg_wait_time: number;
  service_level_pct: number;
}

export function useRealtimeQueues() {
  return useQuery<{ queues: QueueMetric[] }>({
    queryKey: ["dashboard", "realtime"],
    queryFn: async () => {
      const { body } = await get({ apiName: "PortalAPI", path: "/dashboard/realtime" }).response;
      return body.json();
    },
    refetchInterval: 30_000, // poll every 30 seconds
    staleTime: 25_000,
  });
}

export function useTodayMetrics() {
  return useQuery<TodayMetrics>({
    queryKey: ["dashboard", "today"],
    queryFn: async () => {
      const { body } = await get({ apiName: "PortalAPI", path: "/dashboard/today" }).response;
      return body.json();
    },
    refetchInterval: 60_000,
  });
}

KPI Cards Component

TSX
// src/components/dashboard/KpiCards.tsx
import { Phone, Clock, TrendingUp, Users } from "lucide-react";

interface KpiCardsProps {
  data: {
    calls_handled: number;
    avg_handle_time: number;
    service_level_pct: number;
    calls_abandoned: number;
    abandonment_rate: number;
  };
}

export function KpiCards({ data }: KpiCardsProps) {
  const formatTime = (seconds: number) => {
    const m = Math.floor(seconds / 60);
    const s = Math.floor(seconds % 60);
    return `${m}m ${s}s`;
  };

  const kpis = [
    {
      label: "Calls Handled",
      value: data.calls_handled.toLocaleString(),
      icon: Phone,
      trend: null,
      color: "text-blue-500",
      bg: "bg-blue-500/10",
    },
    {
      label: "Avg Handle Time",
      value: formatTime(data.avg_handle_time),
      icon: Clock,
      trend: null,
      color: "text-purple-500",
      bg: "bg-purple-500/10",
    },
    {
      label: "Service Level",
      value: `${data.service_level_pct}%`,
      icon: TrendingUp,
      trend: data.service_level_pct >= 80 ? "good" : "bad",
      color: data.service_level_pct >= 80 ? "text-green-500" : "text-red-500",
      bg: data.service_level_pct >= 80 ? "bg-green-500/10" : "bg-red-500/10",
    },
    {
      label: "Abandonment Rate",
      value: `${data.abandonment_rate}%`,
      icon: Users,
      trend: data.abandonment_rate <= 5 ? "good" : "bad",
      color: data.abandonment_rate <= 5 ? "text-green-500" : "text-amber-500",
      bg: data.abandonment_rate <= 5 ? "bg-green-500/10" : "bg-amber-500/10",
    },
  ];

  return (
    <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
      {kpis.map((kpi) => (
        <div key={kpi.label} className="rounded-xl border border-border bg-card p-5">
          <div className="flex items-center justify-between mb-3">
            <span className="text-sm text-muted-foreground">{kpi.label}</span>
            <div className={`p-2 rounded-lg ${kpi.bg}`}>
              <kpi.icon className={`w-4 h-4 ${kpi.color}`} />
            </div>
          </div>
          <p className={`text-2xl font-bold ${kpi.color}`}>{kpi.value}</p>
        </div>
      ))}
    </div>
  );
}

Live Queue Board

TSX
// src/components/dashboard/QueueBoard.tsx
import { Badge } from "@/components/ui/badge";

interface QueueBoardProps {
  queues: {
    queue_name: string;
    contacts_waiting: number;
    oldest_wait_seconds: number;
    agents_available: number;
    agents_on_call: number;
  }[];
  updatedAt: string;
}

export function QueueBoard({ queues, updatedAt }: QueueBoardProps) {
  const formatAge = (seconds: number) => {
    if (seconds < 60) return `${seconds}s`;
    return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
  };

  return (
    <div className="rounded-xl border border-border bg-card">
      <div className="flex items-center justify-between p-4 border-b border-border">
        <h3 className="font-semibold">Live Queue Status</h3>
        <span className="text-xs text-muted-foreground">
          Updated {new Date(updatedAt).toLocaleTimeString()}
        </span>
      </div>
      <div className="divide-y divide-border">
        {queues.map((q) => (
          <div key={q.queue_name} className="flex items-center justify-between p-4">
            <div>
              <p className="font-medium capitalize">{q.queue_name.replace(/-/g, " ")}</p>
              <p className="text-sm text-muted-foreground">
                {q.agents_on_call} on call · {q.agents_available} available
              </p>
            </div>
            <div className="flex items-center gap-3">
              {q.contacts_waiting > 0 && (
                <div className="text-right">
                  <Badge variant={q.contacts_waiting > 5 ? "destructive" : "secondary"}>
                    {q.contacts_waiting} waiting
                  </Badge>
                  <p className="text-xs text-muted-foreground mt-1">
                    Oldest: {formatAge(q.oldest_wait_seconds)}
                  </p>
                </div>
              )}
              {q.contacts_waiting === 0 && (
                <Badge className="bg-green-500/20 text-green-500 border-0">Clear</Badge>
              )}
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

Hourly Volume Chart

TSX
// src/components/dashboard/HourlyChart.tsx
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from "recharts";

interface HourlyChartProps {
  data: { hour: string; handled: number; abandoned: number }[];
}

export function HourlyChart({ data }: HourlyChartProps) {
  return (
    <div className="rounded-xl border border-border bg-card p-5">
      <h3 className="font-semibold mb-4">Call Volume Today</h3>
      <ResponsiveContainer width="100%" height={220}>
        <BarChart data={data} margin={{ top: 0, right: 0, left: -20, bottom: 0 }}>
          <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
          <XAxis
            dataKey="hour"
            tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
          />
          <YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} />
          <Tooltip
            contentStyle={{
              background: "hsl(var(--card))",
              border: "1px solid hsl(var(--border))",
              borderRadius: "8px",
            }}
          />
          <Bar dataKey="handled"   name="Handled"   fill="#6366f1" radius={[3,3,0,0]} />
          <Bar dataKey="abandoned" name="Abandoned" fill="#f43f5e" radius={[3,3,0,0]} />
        </BarChart>
      </ResponsiveContainer>
    </div>
  );
}

Full Dashboard Page

TSX
// src/pages/Dashboard.tsx
import { useRealtimeQueues, useTodayMetrics } from "@/hooks/useDashboardMetrics";
import { KpiCards } from "@/components/dashboard/KpiCards";
import { QueueBoard } from "@/components/dashboard/QueueBoard";
import { HourlyChart } from "@/components/dashboard/HourlyChart";

export function Dashboard() {
  const { data: realtimeData, isLoading: rtLoading } = useRealtimeQueues();
  const { data: todayData, isLoading: todayLoading } = useTodayMetrics();

  if (rtLoading || todayLoading) {
    return <div className="p-8 text-center text-muted-foreground">Loading dashboard...</div>;
  }

  return (
    <div className="p-6 max-w-7xl mx-auto space-y-6">
      <div className="flex items-center justify-between">
        <h1 className="text-2xl font-bold">Operations Dashboard</h1>
        <span className="text-sm text-muted-foreground">
          {new Date().toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" })}
        </span>
      </div>

      {todayData && <KpiCards data={todayData} />}

      <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
        {realtimeData && (
          <QueueBoard
            queues={realtimeData.queues}
            updatedAt={realtimeData.updated_at}
          />
        )}
        {/* Hourly chart data fetched from historical metrics Lambda */}
      </div>
    </div>
  );
}