Build production-ready analytics dashboards with real-time metrics, historical trends, and engagement tracking using React and Chart.js.
# Install WAVE SDK and analytics dependencies npm install @wave/sdk @wave/analytics-sdk # Install charting library npm install chart.js react-chartjs-2 # Install date utilities npm install date-fns # Install export utilities npm install jspdf jspdf-autotable html2canvas # TypeScript types npm install -D @types/chart.js
// types/analytics.ts
export interface StreamAnalytics {
streamId: string;
status: 'live' | 'ended' | 'scheduled';
startTime: string;
endTime?: string;
duration: number; // seconds
// Real-time metrics
currentViewers: number;
peakConcurrentViewers: number;
totalViews: number;
// Engagement metrics
averageWatchDuration: number; // seconds
engagementRate: number; // percentage
chatMessages: number;
reactions: number;
shares: number;
// Quality metrics
averageBitrate: number; // kbps
bufferRatio: number; // percentage
qualityDistribution: {
'1080p': number;
'720p': number;
'480p': number;
'360p': number;
};
// Geographic data
viewersByCountry: Record<string, number>;
viewersByCity: Record<string, number>;
// Device breakdown
deviceTypes: {
desktop: number;
mobile: number;
tablet: number;
smartTV: number;
};
// Traffic sources
trafficSources: {
direct: number;
social: number;
search: number;
embedded: number;
};
}
export interface HistoricalData {
timestamp: string;
viewers: number;
bitrate: number;
bufferEvents: number;
}
export interface RevenueMetrics {
totalRevenue: number;
currency: string;
breakdown: {
subscriptions: number;
donations: number;
advertising: number;
sponsorships: number;
};
topDonors: Array<{
username: string;
amount: number;
}>;
}
export interface AlertConfig {
type: 'viewers' | 'bitrate' | 'buffer' | 'engagement';
threshold: number;
comparison: 'above' | 'below';
enabled: boolean;
}Production-ready React component with real-time updates
// components/StreamAnalyticsDashboard.tsx
'use client';
import React, { useEffect, useState, useCallback } from 'react';
import { WaveAnalyticsClient } from '@wave/analytics-sdk';
import { Line, Doughnut, Bar } from 'react-chartjs-2';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
ArcElement,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js';
import { format, subHours, subDays } from 'date-fns';
import type { StreamAnalytics, HistoricalData } from '@/types/analytics';
// Register Chart.js components
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
ArcElement,
Title,
Tooltip,
Legend,
Filler
);
interface StreamAnalyticsDashboardProps {
streamId: string;
apiKey: string;
refreshInterval?: number; // milliseconds
enableRealtime?: boolean;
}
export const StreamAnalyticsDashboard: React.FC<StreamAnalyticsDashboardProps> = ({
streamId,
apiKey,
refreshInterval = 5000,
enableRealtime = true
}) => {
// State management
const [analytics, setAnalytics] = useState<StreamAnalytics | null>(null);
const [historicalData, setHistoricalData] = useState<HistoricalData[]>([]);
const [timeRange, setTimeRange] = useState<'1h' | '24h' | '7d' | '30d'>('24h');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [wsConnected, setWsConnected] = useState(false);
// Initialize analytics client
const [analyticsClient] = useState(() =>
new WaveAnalyticsClient({ apiKey })
);
// Fetch initial analytics data
const fetchAnalytics = useCallback(async () => {
try {
const data = await analyticsClient.getStreamAnalytics(streamId);
setAnalytics(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch analytics');
} finally {
setIsLoading(false);
}
}, [analyticsClient, streamId]);
// Fetch historical data
const fetchHistoricalData = useCallback(async () => {
try {
const now = new Date();
const startTime = {
'1h': subHours(now, 1),
'24h': subDays(now, 1),
'7d': subDays(now, 7),
'30d': subDays(now, 30)
}[timeRange];
const data = await analyticsClient.getHistoricalData(streamId, {
startTime: startTime.toISOString(),
endTime: now.toISOString(),
granularity: timeRange === '1h' ? '1m' : timeRange === '24h' ? '5m' : '1h'
});
setHistoricalData(data);
} catch (err) {
console.error('Failed to fetch historical data:', err);
}
}, [analyticsClient, streamId, timeRange]);
// Setup real-time WebSocket connection
useEffect(() => {
if (!enableRealtime) return;
const ws = analyticsClient.subscribeToRealtimeUpdates(streamId);
ws.on('connect', () => {
setWsConnected(true);
});
ws.on('disconnect', () => {
setWsConnected(false);
});
ws.on('analytics', (update: Partial<StreamAnalytics>) => {
setAnalytics(prev => prev ? { ...prev, ...update } : null);
});
ws.on('historical', (dataPoint: HistoricalData) => {
setHistoricalData(prev => [...prev.slice(-99), dataPoint]);
});
return () => {
ws.disconnect();
};
}, [analyticsClient, streamId, enableRealtime]);
// Periodic refresh for non-realtime mode
useEffect(() => {
if (enableRealtime) return;
const interval = setInterval(fetchAnalytics, refreshInterval);
return () => clearInterval(interval);
}, [enableRealtime, fetchAnalytics, refreshInterval]);
// Initial data fetch
useEffect(() => {
fetchAnalytics();
fetchHistoricalData();
}, [fetchAnalytics, fetchHistoricalData]);
// Loading state
if (isLoading) {
return (
<div className="flex items-center justify-center p-12">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary-500"></div>
</div>
);
}
// Error state
if (error) {
return (
<div className="p-6 bg-error-50 border border-error-200 rounded-lg">
<h3 className={combineTokens(DesignTokens.typography.body, 'font-semibold text-error-700 mb-2')}>Error Loading Analytics</h3>
<p className="text-sm text-error-600">{error}</p>
</div>
);
}
if (!analytics) return null;
// Chart data configurations
const viewersTrendData = {
labels: historicalData.map(d => format(new Date(d.timestamp), 'HH:mm')),
datasets: [
{
label: 'Concurrent Viewers',
data: historicalData.map(d => d.viewers),
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true,
tension: 0.4
}
]
};
const qualityDistributionData = {
labels: Object.keys(analytics.qualityDistribution),
datasets: [
{
data: Object.values(analytics.qualityDistribution),
backgroundColor: [
'rgba(59, 130, 246, 0.8)',
'rgba(16, 185, 129, 0.8)',
'rgba(245, 158, 11, 0.8)',
'rgba(239, 68, 68, 0.8)'
],
borderWidth: 0
}
]
};
const deviceBreakdownData = {
labels: Object.keys(analytics.deviceTypes).map(k =>
k.charAt(0).toUpperCase() + k.slice(1)
),
datasets: [
{
data: Object.values(analytics.deviceTypes),
backgroundColor: [
'rgba(139, 92, 246, 0.8)',
'rgba(236, 72, 153, 0.8)',
'rgba(251, 146, 60, 0.8)',
'rgba(34, 197, 94, 0.8)'
]
}
]
};
return (
<div className="space-y-6">
{/* Header with Real-time Status */}
<div className="flex items-center justify-between">
<div>
<h2 className={`${DesignTokens.typography.h3} text-text-primary`}>Stream Analytics</h2>
<p className="text-sm text-text-secondary mt-1">
Stream ID: {streamId}
</p>
</div>
<div className="flex items-center gap-4">
{/* Real-time indicator */}
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${wsConnected ? 'bg-success-500 animate-pulse' : 'bg-error-500'}`} />
<span className="text-sm text-text-secondary">
{wsConnected ? 'Live' : 'Offline'}
</span>
</div>
{/* Time range selector */}
<select
value={timeRange}
onChange={(e) => setTimeRange(e.target.value as any)}
className="px-3 py-2 border border-border-primary rounded-md text-sm bg-white"
>
<option value="1h">Last Hour</option>
<option value="24h">Last 24 Hours</option>
<option value="7d">Last 7 Days</option>
<option value="30d">Last 30 Days</option>
</select>
</div>
</div>
{/* Key Metrics Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
title="Current Viewers"
value={analytics.currentViewers.toLocaleString()}
change={calculateChange(analytics.currentViewers, analytics.peakConcurrentViewers)}
icon={
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
}
/>
<MetricCard
title="Total Views"
value={analytics.totalViews.toLocaleString()}
subtitle={`Peak: ${analytics.peakConcurrentViewers.toLocaleString()}`}
icon={
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
}
/>
<MetricCard
title="Avg Watch Time"
value={formatDuration(analytics.averageWatchDuration)}
subtitle={`Engagement: ${analytics.engagementRate.toFixed(1)}%`}
icon={
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
}
/>
<MetricCard
title="Chat Messages"
value={analytics.chatMessages.toLocaleString()}
subtitle={`${analytics.reactions.toLocaleString()} reactions`}
icon={
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
}
/>
</div>
{/* Charts Row 1 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Viewers Trend Chart */}
<div className="bg-white p-6 rounded-lg border border-border-primary">
<h3 className={combineTokens(DesignTokens.typography.body, " font-semibold text-text-primary mb-4")}>
Viewer Trends
</h3>
<Line
data={viewersTrendData}
options={{
responsive: true,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (context) => `${context.parsed.y.toLocaleString()} viewers`
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: (value) => value.toLocaleString()
}
}
}
}}
/>
</div>
{/* Quality Distribution Chart */}
<div className="bg-white p-6 rounded-lg border border-border-primary">
<h3 className={combineTokens(DesignTokens.typography.body, " font-semibold text-text-primary mb-4")}>
Quality Distribution
</h3>
<Doughnut
data={qualityDistributionData}
options={{
responsive: true,
plugins: {
legend: { position: 'bottom' },
tooltip: {
callbacks: {
label: (context) => {
const label = context.label || '';
const value = context.parsed || 0;
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return `${label}: ${percentage}%`;
}
}
}
}
}}
/>
</div>
</div>
{/* Charts Row 2 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Device Breakdown */}
<div className="bg-white p-6 rounded-lg border border-border-primary">
<h3 className={combineTokens(DesignTokens.typography.body, " font-semibold text-text-primary mb-4")}>
Device Types
</h3>
<Doughnut
data={deviceBreakdownData}
options={{
responsive: true,
plugins: {
legend: { position: 'bottom' }
}
}}
/>
</div>
{/* Geographic Distribution */}
<div className="bg-white p-6 rounded-lg border border-border-primary col-span-2">
<h3 className={combineTokens(DesignTokens.typography.body, " font-semibold text-text-primary mb-4")}>
Top Locations
</h3>
<div className="space-y-3">
{Object.entries(analytics.viewersByCountry)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([country, viewers]) => {
const percentage = (viewers / analytics.totalViews) * 100;
return (
<div key={country}>
<div className="flex justify-between text-sm mb-1">
<span className="text-text-primary font-medium">{country}</span>
<span className="text-text-secondary">
{viewers.toLocaleString()} ({percentage.toFixed(1)}%)
</span>
</div>
<div className="w-full bg-background-secondary rounded-full h-2">
<div
className="bg-primary-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
</div>
{/* Export Actions */}
<div className="flex justify-end gap-3">
<button
onClick={() => exportToCSV(analytics, historicalData)}
className="px-4 py-2 bg-background-secondary text-text-primary rounded-md hover:bg-background-tertiary transition-colors flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Export CSV
</button>
<button
onClick={() => exportToPDF(analytics)}
className="px-4 py-2 bg-primary-500 text-white rounded-md hover:bg-primary-600 transition-colors flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
Export PDF
</button>
</div>
</div>
);
};
// Helper Components
interface MetricCardProps {
title: string;
value: string;
subtitle?: string;
change?: number;
icon: React.ReactNode;
}
const MetricCard: React.FC<MetricCardProps> = ({ title, value, subtitle, change, icon }) => (
<div className="bg-white p-6 rounded-lg border border-border-primary">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-text-secondary mb-1">{title}</p>
<p className={`${DesignTokens.typography.h4} text-text-primary`}>{value}</p>
{subtitle && (
<p className="text-xs text-text-secondary mt-1">{subtitle}</p>
)}
</div>
<div className="p-3 bg-primary-50 rounded-lg text-primary-600">
{icon}
</div>
</div>
{change !== undefined && (
<div className={`mt-3 flex items-center gap-1 text-sm ${change >= 0 ? 'text-success-600' : 'text-error-600'}`}>
<svg className={`w-4 h-4 ${change >= 0 ? '' : 'rotate-180'}`} fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M5.293 7.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L6.707 7.707a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
{Math.abs(change).toFixed(1)}% from peak
</div>
)}
</div>
);
// Utility Functions
function calculateChange(current: number, peak: number): number {
if (peak === 0) return 0;
return ((current - peak) / peak) * 100;
}
function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
function exportToCSV(analytics: StreamAnalytics, historical: HistoricalData[]) {
const rows = [
['Timestamp', 'Viewers', 'Bitrate (kbps)', 'Buffer Events'],
...historical.map(d => [
d.timestamp,
d.viewers.toString(),
d.bitrate.toString(),
d.bufferEvents.toString()
])
];
const csvContent = rows.map(row => row.join(',')).join('\n');
const blob = new Blob([csvContent], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `analytics-${analytics.streamId}-${Date.now()}.csv`;
a.click();
URL.revokeObjectURL(url);
}
async function exportToPDF(analytics: StreamAnalytics) {
const { jsPDF } = await import('jspdf');
const doc = new jsPDF();
doc.setFontSize(20);
doc.text('Stream Analytics Report', 20, 20);
doc.setFontSize(12);
doc.text(`Stream ID: ${analytics.streamId}`, 20, 35);
doc.text(`Generated: ${new Date().toLocaleString()}`, 20, 42);
doc.setFontSize(14);
doc.text('Key Metrics', 20, 55);
doc.setFontSize(10);
const metrics = [
`Total Views: ${analytics.totalViews.toLocaleString()}`,
`Peak Concurrent Viewers: ${analytics.peakConcurrentViewers.toLocaleString()}`,
`Average Watch Duration: ${formatDuration(analytics.averageWatchDuration)}`,
`Engagement Rate: ${analytics.engagementRate.toFixed(1)}%`,
`Chat Messages: ${analytics.chatMessages.toLocaleString()}`,
`Reactions: ${analytics.reactions.toLocaleString()}`
];
metrics.forEach((metric, index) => {
doc.text(metric, 20, 65 + (index * 7));
});
doc.save(`analytics-${analytics.streamId}-${Date.now()}.pdf`);
}
export default StreamAnalyticsDashboard;// hooks/useRealtimeAnalytics.ts
import { useEffect, useState, useCallback } from 'react';
import { WaveAnalyticsClient } from '@wave/analytics-sdk';
import type { StreamAnalytics } from '@/types/analytics';
export function useRealtimeAnalytics(
streamId: string,
apiKey: string,
options: {
enabled?: boolean;
reconnectInterval?: number;
} = {}
) {
const { enabled = true, reconnectInterval = 5000 } = options;
const [analytics, setAnalytics] = useState<StreamAnalytics | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [client] = useState(() => new WaveAnalyticsClient({ apiKey }));
useEffect(() => {
if (!enabled) return;
let ws: any;
let reconnectTimeout: NodeJS.Timeout;
const connect = () => {
try {
ws = client.subscribeToRealtimeUpdates(streamId);
ws.on('connect', () => {
setIsConnected(true);
setError(null);
});
ws.on('disconnect', () => {
setIsConnected(false);
// Attempt reconnection
reconnectTimeout = setTimeout(connect, reconnectInterval);
});
ws.on('analytics', (update: Partial<StreamAnalytics>) => {
setAnalytics(prev => prev ? { ...prev, ...update } : null);
});
ws.on('error', (err: Error) => {
setError(err);
setIsConnected(false);
});
} catch (err) {
setError(err as Error);
}
};
// Initial connection
connect();
// Cleanup
return () => {
if (ws) ws.disconnect();
if (reconnectTimeout) clearTimeout(reconnectTimeout);
};
}, [client, streamId, enabled, reconnectInterval]);
const refresh = useCallback(async () => {
try {
const data = await client.getStreamAnalytics(streamId);
setAnalytics(data);
setError(null);
} catch (err) {
setError(err as Error);
}
}, [client, streamId]);
return {
analytics,
isConnected,
error,
refresh
};
}// components/AnalyticsAlerts.tsx
import { useEffect, useState } from 'react';
import { WaveAnalyticsClient } from '@wave/analytics-sdk';
import type { AlertConfig } from '@/types/analytics';
import { DesignTokens, getContainer, getSection } from '@/lib/design-tokens';
export function setupAnalyticsAlerts(
client: WaveAnalyticsClient,
streamId: string,
alerts: AlertConfig[]
) {
alerts.forEach(alert => {
if (!alert.enabled) return;
client.createAlert({
streamId,
type: alert.type,
condition: {
metric: alert.type,
threshold: alert.threshold,
comparison: alert.comparison
},
actions: [
{
type: 'webhook',
url: 'https://your-webhook-url.com/alerts',
method: 'POST'
},
{
type: 'email',
recipients: ['[email protected]']
},
{
type: 'push',
service: 'fcm', // Firebase Cloud Messaging
tokens: ['device-token-here']
}
]
});
});
}
// Example usage
const alertConfigs: AlertConfig[] = [
{
type: 'viewers',
threshold: 10000,
comparison: 'above',
enabled: true
},
{
type: 'bitrate',
threshold: 1000,
comparison: 'below',
enabled: true
},
{
type: 'buffer',
threshold: 5, // percentage
comparison: 'above',
enabled: true
},
{
type: 'engagement',
threshold: 20, // percentage
comparison: 'below',
enabled: true
}
];