Back to blog
Frontend Engineeringintermediate

Real-Time Call Analytics Dashboard with React & AWS

Build a live call analytics dashboard for a contact center โ€” real-time queue metrics via API polling and WebSockets, historical charts with Recharts, agent performance tables, and DynamoDB Streams for live updates.

LearnixoApril 16, 20266 min read
ReactTypeScriptDashboardAnalyticsAWSReal-TimeRechartsWebSocket
Share:๐•

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>
  );
}

WebSocket & Real-Time Knowledge Check

5 questions ยท Test what you just learned ยท Instant explanations

Enjoyed this article?

Explore the Frontend Engineering learning path for more.

Found this helpful?

Share:๐•

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.