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
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 updatesBackend: 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?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.