/** * @file Analytics.tsx * @description Provides a comprehensive analytics dashboard for monitoring Claude Code sessions, agents, token usage, and events in real-time. Features include an activity heatmap, token distribution charts, session outcome breakdowns, and more, all with interactive tooltips and live updates via WebSocket. * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) * ============================================================================= * **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode. * * ## Design constraints * - Local-first: no telemetry leaves the machine unless the user configures webhooks. * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). * - Destructive flows stay behind explicit confirmation modals and server-side gates. * - Internationalization: user-visible strings belong in i18n JSON, not literals here. * * ## Remote data & SSH * Remote Data Sources let operators aggregate multiple machines. SSH entries describe * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every * scoped GET via `?sources=`. Health checks and import history surface in Settings. * * ## Internal dependencies * - `../lib/api` * - `../lib/eventBus` * - `../lib/dataScope` * - `../lib/format` * - `../components/Tip` * - `../components/Skeleton` * - `../lib/types` * * ## Public surface * - `Analytics` — exported API; see TSDoc on the symbol for behavior. * * ## Testing pointers * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. * - Server contract changes require `npm run test:server` and OpenAPI sync. * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `.claude/skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ /* ----------------------------------------------------------------------------- * EXPORT CATALOG — quick index of symbols defined below (documentation only). * ----------------------------------------------------------------------------- * **Analytics** * Part of this module's public contract. Downstream imports should treat * the signature and return type as stable unless release notes say otherwise. * When behavior changes, update the `@file` overview and relevant tests. * * ----------------------------------------------------------------------------- */ import { useEffect, useState, useCallback, useMemo, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import { RefreshCw, Download, Zap, Bot, FolderOpen, Cpu, DollarSign, Clock, BarChart3, } from "lucide-react"; import { api } from "../lib/api"; import { eventBus } from "../lib/eventBus"; import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents"; import { useDataScope } from "../lib/dataScope"; import { fmt, fmtCost, fmtCostFull, formatModelName } from "../lib/format"; import { Tip } from "../components/Tip"; import { Skeleton, StatValueSkeleton, TextSkeleton } from "../components/Skeleton"; import type { Analytics as AnalyticsData, CostResult } from "../lib/types"; // ── Tooltip ─────────────────────────────────────────────────────────────────── function ChartTooltip({ x, y, children }: { x: number; y: number; children: React.ReactNode }) { const nearRight = x > window.innerWidth - 200; return (
{children}
); } function useTooltip() { const [tooltip, setTooltip] = useState<{ x: number; y: number; content: React.ReactNode; } | null>(null); const show = (e: React.MouseEvent, content: React.ReactNode) => { setTooltip({ x: e.clientX, y: e.clientY, content }); }; const move = (e: React.MouseEvent) => { setTooltip((t) => t && { ...t, x: e.clientX, y: e.clientY }); }; const hide = () => setTooltip(null); const node = tooltip ? ( {tooltip.content} ) : null; return { show, move, hide, node }; } // ── Heatmap ────────────────────────────────────────────────────────────────── function cellColor(count: number, max: number) { if (count === 0) return "#161625"; // Log scale + RGB interpolation across a wide color ramp for maximum perceptual range const t = Math.log(count + 1) / Math.log(Math.max(max, 1) + 1); // Ramp: near-black indigo → deep indigo → bright indigo → lavender type RGB = [number, number, number]; const stops: RGB[] = [ [22, 20, 60], // near-black indigo [55, 48, 163], // deep indigo [99, 102, 241], // bright indigo [199, 210, 254], // lavender ]; const scaled = t * (stops.length - 1); const lo = Math.min(Math.floor(scaled), stops.length - 2); const frac = scaled - lo; const [r1, g1, b1]: RGB = stops[lo] as RGB; const [r2, g2, b2]: RGB = stops[lo + 1] as RGB; const r = Math.round(r1 + (r2 - r1) * frac); const g = Math.round(g1 + (g2 - g1) * frac); const b = Math.round(b1 + (b2 - b1) * frac); return `rgb(${r},${g},${b})`; } function Heatmap({ weeks }: { weeks: Array> }) { const { show, move, hide, node } = useTooltip(); const { t, i18n } = useTranslation(["analytics", "common"]); const locale = i18n.resolvedLanguage ?? i18n.language; const monthLabels = useMemo( () => Array.from({ length: 12 }, (_, month) => new Intl.DateTimeFormat(locale, { month: "short" }).format(new Date(2026, month, 1)) ), [locale] ); const dayNames = useMemo( () => Array.from({ length: 7 }, (_, day) => new Intl.DateTimeFormat(locale, { weekday: "short" }).format( new Date(2026, 0, 4 + day) // Jan 4, 2026 is Sunday ) ), [locale] ); // Labels on the left: Sun (0), Tue (2), Thu (4) const dayLabels = [dayNames[0], "", dayNames[2], "", dayNames[4], "", ""]; const maxCount = Math.max(...weeks.flatMap((w) => w.map((c) => c.count)), 1); // Compute month label positions accurately. A label is ~3 columns wide (each // column is 16px, a 3-letter month ≈ 22px). The very first month is often a // stub - the 52-week window starts mid-month, so e.g. "May" sits at col 0 and // "Jun" at col 1 and they overlap. Drop the *stub* (the earlier, partial one) // rather than the full month after it, so the next label isn't pushed away. const monthPositions = useMemo(() => { const MIN_LABEL_GAP = 3; // columns a label needs to itself const starts: Array<{ label: string; col: number }> = []; let prevMonth = -1; weeks.forEach((week, wi) => { const firstCell = week[0]; if (!firstCell) return; const parts = firstCell.date.split("-").map(Number); const m = (parts[1] || 1) - 1; // 0-indexed month if (m !== prevMonth) { starts.push({ label: monthLabels[m] ?? "", col: wi }); prevMonth = m; } }); // Drop a label when the *next* month starts too soon after it (it would // collide). Suppressing the earlier label leaves the later, full month in // place - no gap is introduced. return starts.filter((s, i) => { const next = starts[i + 1]; return !next || next.col - s.col >= MIN_LABEL_GAP; }); }, [weeks, monthLabels]); return (
{node} {/* Month labels */}
{monthPositions.map((mp, i) => (
{mp.label}
))}
{/* Day labels */}
{dayLabels.map((d, i) => (
{d}
))}
{/* Cells */} {weeks.map((week, wi) => (
{week.map((cell) => (
{ const parts = cell.date.split("-").map(Number); const y = parts[0] || 0; const m = (parts[1] || 1) - 1; const d = parts[2] || 1; const date = new Date(y, m, d, 12); const dow = date.getDay(); show( e, <> {dayNames[dow] ?? ""}, {cell.date} {t("eventCountLabel", { count: cell.count })} ); }} onMouseMove={move} onMouseLeave={hide} style={{ width: 13, height: 13, borderRadius: 2, backgroundColor: cellColor(cell.count, maxCount), border: "1px solid rgba(255,255,255,0.04)", flexShrink: 0, cursor: "default", }} /> ))}
))}
{/* Legend */}
{t("less")} {[0, 0.25, 0.5, 0.75, 1].map((f) => { const v = Math.round(f * maxCount); return (
); })} {t("more")}
); } // ── Sparkline bar chart ─────────────────────────────────────────────────────── function Sparkline({ data, color = "#6366f1", }: { data: Array<{ date: string; count: number }>; color?: string; }) { const { t } = useTranslation("analytics"); const { show, move, hide, node } = useTooltip(); const max = Math.max(...data.map((d) => d.count), 1); return (
{node} {data.map(({ date, count }) => (
show( e, <> {date} {t("eventCountLabel", { count })} ) } onMouseMove={move} onMouseLeave={hide} /> ))}
); } function CostTrendLine({ data, color = "#10b981", }: { data: Array<{ date: string; cost: number }>; color?: string; }) { const { show, move, hide, node } = useTooltip(); if (data.length === 0) return null; const width = 320; const height = 88; const padX = 8; const padY = 8; const min = Math.min(...data.map((d) => d.cost), 0); const max = Math.max(...data.map((d) => d.cost), 0); const span = Math.max(max - min, 0.0001); const step = data.length > 1 ? (width - padX * 2) / (data.length - 1) : 0; const points = data.map(({ date, cost }, i) => { const x = padX + i * step; const y = height - padY - ((cost - min) / span) * (height - padY * 2); return { date, cost, x, y }; }); const linePoints = points.map((p) => `${p.x},${p.y}`).join(" "); const firstX = points[0]?.x ?? padX; const lastX = points[points.length - 1]?.x ?? padX; const areaPoints = `${firstX},${height - padY} ${linePoints} ${lastX},${height - padY}`; return (
{node} {points.map((point) => ( show( e, <> {point.date} {fmtCostFull(point.cost)} ) } onMouseMove={move} onMouseLeave={hide} /> ))}
); } // ── Bar row ─────────────────────────────────────────────────────────────────── function BarRow({ label, count, max, color = "bg-accent", pct, }: { label: string; count: number; max: number; color?: string; pct?: number; }) { const width = pct !== undefined ? pct : max > 0 ? Math.round((count / max) * 100) : 0; return (
{label}
{fmt(count)}
); } function CostBarRow({ label, cost, max, color = "bg-status-success", }: { label: string; cost: number; max: number; color?: string; }) { const width = max > 0 ? Math.max(2, Math.round((cost / max) * 100)) : 0; return (
{label}
{fmtCost(cost)}
); } // ── Donut segment via SVG ───────────────────────────────────────────────────── function DonutChart({ segments, formatTotal, }: { segments: Array<{ label: string; value: number; color: string }>; formatTotal?: (total: number) => string; }) { const { t } = useTranslation(["analytics", "common"]); const { show, move, hide, node } = useTooltip(); const total = segments.reduce((s, g) => s + g.value, 0); if (total === 0) return
{t("common:noData")}
; const r = 52; const cx = 64; const cy = 64; const stroke = 18; const circumference = 2 * Math.PI * r; // offset starts at circumference/4 (top of circle) and decrements by each segment's arc. // strokeDashoffset = offset (not negated) is the correct formula for starting at 12 o'clock. let offset = circumference / 4; return (
{node} {segments.map(({ label, value, color }, i) => { const dash = (value / total) * circumference; const gap = circumference - dash; const pct = Math.round((value / total) * 100); const currentOffset = offset; offset -= dash; return ( show( e, <> {label} {pct}% ) } onMouseMove={move} onMouseLeave={hide} /> ); })} {(formatTotal ?? fmt)(total)} {t("common:total_lower")}
{segments.map(({ label, value, color }) => (
{label} {Math.round((value / total) * 100)}%
))}
); } // ── StatPill ────────────────────────────────────────────────────────────────── function StatPill({ label, value, raw, sub, icon: Icon, color = "text-accent", loading = false, }: { label: string; value: string | number; raw?: string; sub?: string; icon: React.ElementType; color?: string; loading?: boolean; }) { return (
{label}
{loading ? ( ) : (

{raw ? {value} : value}

)} {loading ? ( ) : ( sub &&

{sub}

)}
); } // ── Loading skeleton ─────────────────────────────────────────────────────────── /** A single chart-card placeholder: title bar, chart body, two footer lines. * Uses the shared pulsing so the whole grid shimmers while data * loads (mirrors the chart-card layout so nothing jumps when data arrives). */ function ChartCardSkeleton({ className = "", bodyH = "h-32", }: { className?: string; bodyH?: string; }) { return (
); } /** Pulsing placeholder for the Analytics chart region (heatmap + sparkline row, * tab bar, and the three-up chart grid) shown while the analytics payload is * still being fetched. */ function AnalyticsChartsSkeleton() { return (
); } // ── Main page ───────────────────────────────────────────────────────────────── export function Analytics() { const { t, i18n } = useTranslation("analytics"); const [data, setData] = useState(null); const [costData, setCostData] = useState(null); const [lastUpdate, setLastUpdate] = useState(new Date()); const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState<"tokens" | "cost" | "workflow" | "productivity">( "cost" ); const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected); // Global data scope; a change re-runs `load` (api injects the `sources` param). const [scope] = useDataScope(); const load = useCallback(async () => { try { const [result, cost] = await Promise.all([ api.analytics.get(), api.pricing.totalCost().catch(() => null), ]); setData(result); setCostData(cost); setLastUpdate(new Date()); } finally { setLoading(false); } }, [scope]); useEffect(() => { load(); const interval = setInterval(load, 15000); return () => clearInterval(interval); }, [load]); useEffect(() => { return eventBus.subscribe((msg) => { if ( msg.type === "session_created" || msg.type === "session_updated" || msg.type === "new_event" || msg.type === "agent_created" || isRemoteDataRefreshMessage(msg) ) { load(); } }); }, [load]); function handleExport() { if (!data) return; const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `analytics-${new Date().toISOString().slice(0, 10)}.json`; a.click(); URL.revokeObjectURL(url); } // Format a local Date object as a YYYY-MM-DD string for heatmap lookups function localDateStr(d: Date): string { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return `${y}-${m}-${day}`; } // Server now returns dates grouped by the user's local timezone (via tz_offset), // so we can use them directly without conversion. const dailyMap: Record = {}; for (const d of data?.daily_events ?? []) { dailyMap[d.date] = (dailyMap[d.date] ?? 0) + d.count; } // Build heatmap: 52 weeks × 7 days // Align start to Sunday (day 0) so row indices match day-of-week labels correctly. const today = new Date(); today.setHours(12, 0, 0, 0); // Normalize to noon const startDate = new Date(today); startDate.setDate(today.getDate() - 364); // Exactly 52 weeks ago // Roll back to the previous Sunday const startDow = startDate.getDay(); startDate.setDate(startDate.getDate() - startDow); const weeks: Array> = []; for (let w = 0; w < 53; w++) { const week: Array<{ date: string; count: number }> = []; for (let d = 0; d < 7; d++) { const cell = new Date(startDate); cell.setDate(startDate.getDate() + w * 7 + d); if (cell > today) break; const dateStr = localDateStr(cell); week.push({ date: dateStr, count: dailyMap[dateStr] ?? 0 }); } if (week.length > 0) weeks.push(week); } // Last 30 days for sparkline const last30 = Array.from({ length: 30 }, (_, i) => { const d = new Date(today); d.setDate(today.getDate() - (29 - i)); const dateStr = localDateStr(d); return { date: dateStr, count: dailyMap[dateStr] ?? 0 }; }); // Convert daily_sessions - server already returns local dates const dailySessionsLocal: Array<{ date: string; count: number }> = []; const sessMap: Record = {}; for (const d of data?.daily_sessions ?? []) { sessMap[d.date] = (sessMap[d.date] ?? 0) + d.count; } for (const [date, count] of Object.entries(sessMap)) { dailySessionsLocal.push({ date, count }); } dailySessionsLocal.sort((a, b) => a.date.localeCompare(b.date)); // Convert daily_costs - server already returns local dates const dailyCostsLocal: Array<{ date: string; cost: number }> = []; const costMap: Record = {}; for (const d of costData?.daily_costs ?? []) { costMap[d.date] = (costMap[d.date] ?? 0) + d.cost; } for (const [date, cost] of Object.entries(costMap)) { dailyCostsLocal.push({ date, cost }); } dailyCostsLocal.sort((a, b) => a.date.localeCompare(b.date)); const dailyCostLast30 = Array.from({ length: 30 }, (_, i) => { const d = new Date(today); d.setDate(today.getDate() - (29 - i)); const dateStr = localDateStr(d); return { date: dateStr, cost: costMap[dateStr] ?? 0 }; }); const peakCostDay = dailyCostLast30.reduce( (max, curr) => (curr.cost > max.cost ? curr : max), dailyCostLast30[0] ?? { date: "", cost: 0 } ); const totalCost30d = dailyCostLast30.reduce((sum, day) => sum + day.cost, 0); const costBreakdown = [...(costData?.breakdown ?? [])] .filter((b) => b.cost > 0) .sort((a, b) => b.cost - a.cost); const locale = i18n.resolvedLanguage ?? i18n.language; const weekdayOrder = [1, 2, 3, 4, 5, 6, 0]; const weekdayCosts = weekdayOrder.map((dow) => { const label = new Intl.DateTimeFormat(locale, { weekday: "short" }).format( new Date(Date.UTC(2026, 0, 4 + dow)) ); const cost = dailyCostLast30 .filter((day) => new Date(day.date + "T12:00:00").getDay() === dow) .reduce((sum, day) => sum + day.cost, 0); return { label, cost }; }); const maxWeekdayCost = Math.max(...weekdayCosts.map((d) => d.cost), 1); const totalTokens = (data?.tokens.total_input ?? 0) + (data?.tokens.total_output ?? 0) + (data?.tokens.total_cache_read ?? 0) + (data?.tokens.total_cache_write ?? 0); const tokenMixSegments = [ { label: t("common:token.input"), value: data?.tokens.total_input ?? 0, color: "#60a5fa" }, { label: t("common:token.output"), value: data?.tokens.total_output ?? 0, color: "#34d399" }, { label: t("common:token.cacheRead"), value: data?.tokens.total_cache_read ?? 0, color: "#a78bfa", }, { label: t("common:token.cacheWrite"), value: data?.tokens.total_cache_write ?? 0, color: "#facc15", }, ].filter((s) => s.value > 0); const maxToolCount = data?.tool_usage[0]?.count ?? 1; const maxAgentTypeCount = data?.agent_types[0]?.count ?? 1; const maxEventTypeCount = data?.event_types[0]?.count ?? 1; const cacheHitPct = totalTokens > 0 ? Math.round(((data?.tokens.total_cache_read ?? 0) / totalTokens) * 100) : 0; const sessionOutcomeSegments = [ { label: t("common:status.completed"), value: data?.sessions_by_status?.completed ?? 0, color: "#8b5cf6", }, { label: t("common:status.active"), value: data?.sessions_by_status?.active ?? 0, color: "#10b981", }, { label: t("common:status.error"), value: data?.sessions_by_status?.error ?? 0, color: "#ef4444", }, { label: t("common:status.abandoned"), value: data?.sessions_by_status?.abandoned ?? 0, color: "#f59e0b", }, ].filter((s) => s.value > 0); const agentStatusSegments = [ { label: t("common:status.completed"), value: data?.agents_by_status?.completed ?? 0, color: "#8b5cf6", }, { label: t("common:status.working"), value: data?.agents_by_status?.working ?? 0, color: "#10b981", }, { label: t("common:status.waiting"), value: data?.agents_by_status?.waiting ?? 0, color: "#eab308", }, { label: t("common:status.error"), value: data?.agents_by_status?.error ?? 0, color: "#ef4444", }, ].filter((s) => s.value > 0); const EVENT_TYPE_COLORS: Record = { PreToolUse: "bg-status-success", PostToolUse: "bg-blue-500", Stop: "bg-violet-400", SubagentStop: "bg-yellow-400", Notification: "bg-orange-400", }; return (
{/* Header */}

{t("title")}

{wsConnected ? ( {t("common:live")} ) : ( {t("common:offline")} )}

{t("subtitle")} {lastUpdate.toLocaleTimeString()}

{/* Key stats */}
{!data ? ( ) : ( <> {/* Activity heatmap + 30-day sparkline */}

{t("eventActivity")}

{t("last30Days")}

{t("dailyEventCount")}

{last30[0]?.date?.slice(5)} {last30[last30.length - 1]?.date?.slice(5)}
{t("peakDay")} d.count)).toLocaleString()}> {fmt(Math.max(...last30.map((d) => d.count)))} {" "} {t("common:events")}
{t("total30d")} s + d.count, 0).toLocaleString()}> {fmt(last30.reduce((s, d) => s + d.count, 0))} {" "} {t("common:events")}
{/* Tabs */}
{( [ { key: "cost" as const, label: t("tabs.costAnalytics") }, { key: "tokens" as const, label: t("tabs.tokenAnalytics") }, { key: "productivity" as const, label: t("tabs.productivityAnalytics") }, { key: "workflow" as const, label: t("tabs.workflowIntelligence") }, ] as const ).map(({ key, label }) => ( ))}
{activeTab === "tokens" && (
{/* Token bars */}

{t("tokenDistribution")}

{[ { label: t("common:token.input"), value: data?.tokens.total_input ?? 0, color: "bg-blue-500", }, { label: t("common:token.output"), value: data?.tokens.total_output ?? 0, color: "bg-status-success", }, { label: t("common:token.cacheRead"), value: data?.tokens.total_cache_read ?? 0, color: "bg-violet-400", }, { label: t("common:token.cacheWrite"), value: data?.tokens.total_cache_write ?? 0, color: "bg-yellow-400", }, ].map(({ label, value, color }) => ( ))}
{t("common:token.totalTokens")} {fmt(totalTokens)}
{t("cacheEfficiency")} {cacheHitPct}%
{/* Token summary */}

{t("tokenBreakdown")}

{[ { label: t("common:token.input"), value: data?.tokens.total_input ?? 0, color: "text-blue-500", }, { label: t("common:token.output"), value: data?.tokens.total_output ?? 0, color: "text-status-success", }, { label: t("common:token.cacheRead"), value: data?.tokens.total_cache_read ?? 0, color: "text-violet-400", }, { label: t("common:token.cacheWrite"), value: data?.tokens.total_cache_write ?? 0, color: "text-yellow-400", }, { label: t("common:total"), value: totalTokens, color: "text-fg-primary" }, ].map(({ label, value, color }) => (
{label} {value.toLocaleString()}
))}
{totalTokens === 0 && (

{t("tokenInfo")}

)}
{/* Token mix donut */}

{t("tokenMix")}

{tokenMixSegments.length === 0 ? (

{t("common:noData")}

) : ( <> fmt(total)} />
{tokenMixSegments.map((segment) => (
{segment.label} {fmt(segment.value)}
))}
)}
)} {activeTab === "cost" && (
{/* Daily cost trends */}

{t("dailyCostTrends")}

{dailyCostsLocal.length === 0 ? (

{t("noDailyCostData")}

) : ( <>

{t("costPerDay")}

{dailyCostLast30[0]?.date?.slice(5)} {dailyCostLast30[dailyCostLast30.length - 1]?.date?.slice(5)}
{t("peakCostDay")} {fmtCost(peakCostDay.cost)}
{t("totalCost30d")} {fmtCost(totalCost30d)}
)}
{/* Cost by model */}

{t("costByModel")}

{costBreakdown.length > 0 ? ( <> ({ label: formatModelName(b.model) ?? b.model, value: Math.round(b.cost * 100), color: ["#8b5cf6", "#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#ec4899"][ i % 6 ] ?? "#6b7280", }))} formatTotal={(cents) => fmtCost(cents / 100)} />
{costBreakdown.map((b) => (
{formatModelName(b.model)} {fmtCost(b.cost)}
))}
{t("common:total")} {fmtCost(costData?.total_cost ?? 0)}
) : (

{t("noCostData")}

)}
{/* Cost by weekday */}

{t("costByWeekday")}

{dailyCostsLocal.length === 0 ? (

{t("noDailyCostData")}

) : ( <>

{t("last30Days")}

{weekdayCosts.map(({ label, cost }) => ( ))}
{t("common:total")} {fmtCost(totalCost30d)}
)}
)} {activeTab === "workflow" && (
{/* Agent type distribution */}

{t("subagentTypes")}

{(data?.agent_types ?? []).length === 0 ? (

{t("noSubagentData")}

) : (
{(data?.agent_types ?? []).slice(0, 10).map(({ subagent_type, count }) => ( ))}
)}
{/* Agent status donut */}

{t("agentStatus")}

{t("totalAgentsLabel")} {fmt(data?.overview.total_agents ?? 0)}
{agentStatusSegments.map((s) => (
{s.label} {fmt(s.value)}
))}
{/* Event type breakdown */}

{t("eventTypes")}

{(data?.event_types ?? []).length === 0 ? (

{t("noEventData")}

) : (
{(data?.event_types ?? []).map(({ event_type, count }) => ( ))}
)}
)} {activeTab === "productivity" && (
{/* Top tools */}

{t("toolUsage")}

{(data?.tool_usage ?? []).length === 0 ? (

{t("noToolData")}

) : (
{(data?.tool_usage ?? []).slice(0, 12).map(({ tool_name, count }) => ( ))}
)}
{/* Session outcomes donut */}

{t("sessionOutcomes")}

{t("totalSessionsLabel")} {fmt(data?.overview.total_sessions ?? 0)}
{sessionOutcomeSegments.map((s) => (
{s.label} {fmt(s.value)}
))}
{/* Daily session trends */}

{t("dailySessionTrends")}

{dailySessionsLocal.length === 0 ? (

{t("noSessionTrendData")}

) : ( <>
{dailySessionsLocal .slice(-7) .reverse() .map(({ date, count }) => { const maxD = Math.max( ...(dailySessionsLocal.length > 0 ? dailySessionsLocal : [{ count: 1 }] ).map((d) => d.count) ); return (
{date.slice(5)}
{count}
); })}

{t("last7Days")}

)}
)}
)}
); }