feat(08-02): create 5 dashboard chart components with ChartSection container

- Add RunActivityChart (area chart), SuccessRateChart (line chart), CostTrendChart (area chart), AgentWorkloadChart (bar chart) using Recharts
- Add BudgetUtilization with CSS progress bars, color thresholds (success/warn/danger), and empty state
- Create ChartSection container orchestrating all charts with Framer Motion entrance animation
- Integrate ChartSection into Dashboard.tsx between MetricsGrid and NeedsAttention
- Add companyId prop to DashboardProps and pass from App.tsx
- Include Plan 01 infrastructure: chart-utils.ts, ChartCard.tsx, ChartTooltip.tsx, recharts dependency
- All charts respect reduced motion via useReducedMotion hook
- 16 unit tests pass for aggregation functions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 4.6 committed 2026-03-27 08:59:59 -04:00
1 parent c1de05a7f1
commit b6c5b2a9ee
14 files changed
+1248 -3

No files matched your search

@@ -0,0 +1,97 @@
import { useMemo } from "react";
import { motion } from "framer-motion";
import type { RunRecord, CostRecord, AgentRecord } from "@shared/types";
import { useReducedMotion } from "../ui";
import {
aggregateRunsByDay,
aggregateCostsByDay,
aggregateAgentWorkload,
} from "./charts/chart-utils";
import { ChartCard } from "./charts/ChartCard";
import { RunActivityChart } from "./charts/RunActivityChart";
import { SuccessRateChart } from "./charts/SuccessRateChart";
import { CostTrendChart } from "./charts/CostTrendChart";
import { AgentWorkloadChart } from "./charts/AgentWorkloadChart";
import { BudgetUtilization } from "./charts/BudgetUtilization";
interface ChartSectionProps {
runs: RunRecord[];
costs: CostRecord[];
agents: AgentRecord[];
companyId: string;
}
export function ChartSection({ runs, costs, agents, companyId }: ChartSectionProps) {
const reducedMotion = useReducedMotion();
const dailyRuns = useMemo(() => aggregateRunsByDay(runs, 14), [runs]);
const dailyCosts = useMemo(() => aggregateCostsByDay(costs, 14), [costs]);
const agentWorkload = useMemo(
() => aggregateAgentWorkload(runs, agents),
[runs, agents],
);
const hasRunData = dailyRuns.some((d) => d.total > 0);
const hasCostData = dailyCosts.some((d) => d.total > 0);
const content = (
<div className="space-y-6">
{/* Section header */}
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">
Performance
</div>
{/* 2-column chart grid */}
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<ChartCard
title="Run Activity"
ariaLabel={`Run activity chart showing ${dailyRuns.reduce((s, d) => s + d.total, 0)} total runs over 14 days`}
isEmpty={!hasRunData}
>
<RunActivityChart data={dailyRuns} reducedMotion={reducedMotion} />
</ChartCard>
<ChartCard
title="Success Rate"
ariaLabel="Success rate chart showing percentage of succeeded runs over 14 days"
isEmpty={!hasRunData}
>
<SuccessRateChart data={dailyRuns} reducedMotion={reducedMotion} />
</ChartCard>
<ChartCard
title="Cost Trend"
ariaLabel="Cost trend chart showing daily spend over 14 days"
isEmpty={!hasCostData}
>
<CostTrendChart data={dailyCosts} reducedMotion={reducedMotion} />
</ChartCard>
<ChartCard
title="Agent Workload"
ariaLabel="Agent workload chart showing run distribution across agents"
isEmpty={agentWorkload.length === 0}
>
<AgentWorkloadChart data={agentWorkload} reducedMotion={reducedMotion} />
</ChartCard>
</div>
{/* Budget Utilization section */}
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">
Budget Utilization
</div>
<BudgetUtilization companyId={companyId} agents={agents} />
</div>
);
if (reducedMotion) {
return content;
}
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
>
{content}
</motion.div>
);
}
@@ -27,9 +27,11 @@ import { MetricCard } from "./MetricsGrid";
import { ActivityFeed } from "./ActivityFeed";
import { RecoveryBanner } from "./RecoveryBanner";
import { BudgetAlertBanner } from "./BudgetAlertBanner";
import { ChartSection } from "./ChartSection";
export interface DashboardProps {
companyName?: string;
companyId?: string;
agents: AgentRecord[];
tasks: TaskRecord[];
runs: RunRecord[];
@@ -44,7 +46,7 @@ export interface DashboardProps {
export function Dashboard(props: DashboardProps) {
const t = useT();
const { companyName, agents, tasks, runs, approvals, activity, costs, messages = [], onNavigate } = props;
const { companyName, companyId, agents, tasks, runs, approvals, activity, costs, messages = [], onNavigate } = props;
const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
const taskMap = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks]);
@@ -131,6 +133,9 @@ export function Dashboard(props: DashboardProps) {
/>
</div>
{/* Performance Charts */}
{companyId ? <ChartSection runs={runs} costs={costs} agents={agents} companyId={companyId} /> : null}
{/* Needs Attention — moved to top, only shows when items exist */}
{hasAttentionItems ? (
<section>
@@ -0,0 +1,52 @@
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import type { AgentWorkloadData } from "./chart-utils";
import { ChartTooltip } from "./ChartTooltip";
interface AgentWorkloadChartProps {
data: AgentWorkloadData[];
reducedMotion: boolean;
}
export function AgentWorkloadChart({ data, reducedMotion }: AgentWorkloadChartProps) {
return (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={data}>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--line)"
horizontal={true}
vertical={false}
/>
<XAxis
dataKey="name"
tick={{ fontSize: 11, fill: "var(--muted)" }}
axisLine={{ stroke: "var(--line)" }}
tickLine={false}
/>
<YAxis
allowDecimals={false}
tick={{ fontSize: 11, fill: "var(--muted)" }}
axisLine={false}
tickLine={false}
/>
<Tooltip content={<ChartTooltip valueSuffix=" runs" />} />
<Bar
dataKey="runs"
fill="var(--accent)"
fillOpacity={0.85}
radius={[4, 4, 0, 0]}
maxBarSize={40}
isAnimationActive={!reducedMotion}
/>
</BarChart>
</ResponsiveContainer>
);
}
@@ -0,0 +1,87 @@
import { useMemo } from "react";
import type { AgentRecord } from "@shared/types";
interface BudgetUtilizationProps {
companyId: string;
agents: AgentRecord[];
}
interface BudgetRow {
id: string;
name: string;
spent: number;
budget: number;
pct: number;
}
function getBarColor(pct: number): string {
if (pct >= 100) return "bg-[color:var(--danger)]";
if (pct >= 80) return "bg-[color:var(--warn)]";
return "bg-[color:var(--success)]";
}
export function BudgetUtilization({ companyId: _companyId, agents }: BudgetUtilizationProps) {
const rows = useMemo<BudgetRow[]>(() => {
return agents
.filter((a) => a.budgetMonthlyUsd > 0)
.map((a) => ({
id: a.id,
name: a.name,
spent: a.spentMonthlyUsd,
budget: a.budgetMonthlyUsd,
pct: Math.round((a.spentMonthlyUsd / a.budgetMonthlyUsd) * 100),
}))
.sort((a, b) => a.name.localeCompare(b.name));
}, [agents]);
if (rows.length === 0) {
return (
<div className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-4">
<div className="flex h-[120px] flex-col items-center justify-center gap-2">
<span className="text-[13px] font-semibold text-[color:var(--muted)]">
No budget policies configured
</span>
<span className="max-w-[200px] text-center text-[11px] text-[color:var(--muted)]">
Set budget policies in Settings to track utilization here.
</span>
</div>
</div>
);
}
return (
<div className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-4">
<div className="space-y-3">
{rows.map((row) => (
<div key={row.id}>
<div className="mb-1 flex items-center justify-between">
<span className="text-[13px] font-semibold text-[color:var(--text)]">
{row.name}
</span>
<span
className="text-[13px] text-[color:var(--muted)]"
style={{
fontFamily: "var(--font-mono)",
fontVariantNumeric: "tabular-nums",
}}
>
{row.pct}%
</span>
</div>
<div className="relative h-1.5 rounded-full bg-[color:var(--panel-soft)]">
<div
className={`h-1.5 rounded-full transition-all duration-300 ${getBarColor(row.pct)}`}
style={{ width: `${Math.min(row.pct, 100)}%` }}
/>
{/* 80% warning marker */}
<div
className="absolute top-0 h-full w-px bg-[color:var(--warn)] opacity-50"
style={{ left: "80%" }}
/>
</div>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,54 @@
import { ChartLine } from "@phosphor-icons/react";
interface ChartCardProps {
title: string;
period?: string;
isEmpty?: boolean;
emptyHeading?: string;
emptyBody?: string;
ariaLabel?: string;
children: React.ReactNode;
}
export function ChartCard({
title,
period = "14d",
isEmpty = false,
emptyHeading = "No activity yet",
emptyBody = "Charts will appear once your company starts running tasks.",
ariaLabel,
children,
}: ChartCardProps) {
return (
<div
className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-4"
role="img"
aria-label={ariaLabel}
>
<div className="mb-3 flex items-center justify-between">
<span className="text-[13px] font-semibold text-[color:var(--text)]">
{title}
</span>
<span className="text-[11px] text-[color:var(--muted)]">
{period}
</span>
</div>
{isEmpty ? (
<div className="flex h-[220px] flex-col items-center justify-center gap-2">
<ChartLine
size={32}
className="text-[color:var(--muted)] opacity-50"
/>
<span className="text-[13px] font-semibold text-[color:var(--muted)]">
{emptyHeading}
</span>
<span className="max-w-[200px] text-center text-[11px] text-[color:var(--muted)]">
{emptyBody}
</span>
</div>
) : (
<div style={{ height: 220 }}>{children}</div>
)}
</div>
);
}
@@ -0,0 +1,57 @@
import type { TooltipContentProps } from "recharts";
interface ChartTooltipExtraProps {
valuePrefix?: string;
valueSuffix?: string;
}
type Props = Partial<TooltipContentProps<number, string>> & ChartTooltipExtraProps;
export function ChartTooltip({
active,
payload,
label,
valuePrefix = "",
valueSuffix = "",
}: Props) {
if (!active || !payload?.length) return null;
const dateStr =
typeof label === "string"
? new Date(label + "T00:00:00").toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
: String(label);
return (
<div
className="rounded-[6px] border border-[color:var(--line-strong)] bg-[color:var(--panel)] px-3 py-2 shadow-md"
style={{ maxWidth: 200, zIndex: 50 }}
>
<div className="text-[11px] text-[color:var(--muted)]">{dateStr}</div>
{payload.map((entry, i) => (
<div key={i} className="mt-1 flex items-center gap-2">
<span
className="inline-block h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span
className="text-[13px] font-semibold text-[color:var(--text)]"
style={{
fontFamily: "var(--font-mono)",
fontVariantNumeric: "tabular-nums",
}}
>
{valuePrefix}
{typeof entry.value === "number"
? entry.value.toLocaleString()
: entry.value}
{valueSuffix}
</span>
</div>
))}
</div>
);
}
@@ -0,0 +1,57 @@
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import type { DailyCostData } from "./chart-utils";
import { formatShortDate } from "./chart-utils";
import { ChartTooltip } from "./ChartTooltip";
interface CostTrendChartProps {
data: DailyCostData[];
reducedMotion: boolean;
}
export function CostTrendChart({ data, reducedMotion }: CostTrendChartProps) {
return (
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={data}>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--line)"
horizontal={true}
vertical={false}
/>
<XAxis
dataKey="date"
tickFormatter={formatShortDate}
tick={{ fontSize: 11, fill: "var(--muted)" }}
axisLine={{ stroke: "var(--line)" }}
tickLine={false}
/>
<YAxis
tickFormatter={(val: number) => "$" + val.toFixed(0)}
tick={{ fontSize: 11, fill: "var(--muted)" }}
axisLine={false}
tickLine={false}
/>
<Tooltip content={<ChartTooltip valuePrefix="$" />} />
<Area
type="monotone"
dataKey="total"
stroke="var(--accent)"
fill="var(--accent-soft)"
fillOpacity={0.15}
strokeWidth={2}
dot={false}
activeDot={{ r: 4, fill: "var(--accent)" }}
isAnimationActive={!reducedMotion}
/>
</AreaChart>
</ResponsiveContainer>
);
}
@@ -0,0 +1,57 @@
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import type { DailyRunData } from "./chart-utils";
import { formatShortDate } from "./chart-utils";
import { ChartTooltip } from "./ChartTooltip";
interface RunActivityChartProps {
data: DailyRunData[];
reducedMotion: boolean;
}
export function RunActivityChart({ data, reducedMotion }: RunActivityChartProps) {
return (
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={data}>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--line)"
horizontal={true}
vertical={false}
/>
<XAxis
dataKey="date"
tickFormatter={formatShortDate}
tick={{ fontSize: 11, fill: "var(--muted)" }}
axisLine={{ stroke: "var(--line)" }}
tickLine={false}
/>
<YAxis
allowDecimals={false}
tick={{ fontSize: 11, fill: "var(--muted)" }}
axisLine={false}
tickLine={false}
/>
<Tooltip content={<ChartTooltip valueSuffix=" runs" />} />
<Area
type="monotone"
dataKey="total"
stroke="var(--accent)"
fill="var(--accent-soft)"
fillOpacity={0.3}
strokeWidth={2}
dot={false}
activeDot={{ r: 4, fill: "var(--accent)" }}
isAnimationActive={!reducedMotion}
/>
</AreaChart>
</ResponsiveContainer>
);
}
@@ -0,0 +1,66 @@
import { useMemo } from "react";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import type { DailyRunData } from "./chart-utils";
import { formatShortDate } from "./chart-utils";
import { ChartTooltip } from "./ChartTooltip";
interface SuccessRateChartProps {
data: DailyRunData[];
reducedMotion: boolean;
}
export function SuccessRateChart({ data, reducedMotion }: SuccessRateChartProps) {
const rateData = useMemo(
() =>
data.map((d) => ({
date: d.date,
rate: d.total > 0 ? Math.round((d.succeeded / d.total) * 100) : 0,
})),
[data],
);
return (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={rateData}>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--line)"
horizontal={true}
vertical={false}
/>
<XAxis
dataKey="date"
tickFormatter={formatShortDate}
tick={{ fontSize: 11, fill: "var(--muted)" }}
axisLine={{ stroke: "var(--line)" }}
tickLine={false}
/>
<YAxis
domain={[0, 100]}
tickFormatter={(val: number) => `${val}%`}
tick={{ fontSize: 11, fill: "var(--muted)" }}
axisLine={false}
tickLine={false}
/>
<Tooltip content={<ChartTooltip valueSuffix="%" />} />
<Line
type="monotone"
dataKey="rate"
stroke="var(--success)"
strokeWidth={2}
dot={false}
activeDot={{ r: 4, fill: "var(--success)" }}
isAnimationActive={!reducedMotion}
/>
</LineChart>
</ResponsiveContainer>
);
}
@@ -0,0 +1,142 @@
import type { RunRecord, CostRecord, AgentRecord } from "@shared/types";
export interface DailyRunData {
date: string; // "YYYY-MM-DD"
total: number;
succeeded: number;
failed: number;
}
export interface DailyCostData {
date: string; // "YYYY-MM-DD"
total: number; // USD amount (NOT cents)
}
export interface AgentWorkloadData {
name: string; // agent name, truncated to 10 chars
runs: number;
}
/**
* Aggregate runs into daily buckets for chart consumption.
* Returns `days` entries sorted ascending by date (oldest first).
* Days with no runs have {total:0, succeeded:0, failed:0}.
*/
export function aggregateRunsByDay(runs: RunRecord[], days: number): DailyRunData[] {
const now = new Date();
const buckets = new Map<string, { total: number; succeeded: number; failed: number }>();
// Initialize all day buckets with zero values
for (let i = 0; i < days; i++) {
const date = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
const key = date.toISOString().slice(0, 10);
buckets.set(key, { total: 0, succeeded: 0, failed: 0 });
}
// Fill buckets from actual data
for (const run of runs) {
const day = run.createdAt.slice(0, 10);
const bucket = buckets.get(day);
if (!bucket) continue;
bucket.total++;
if (run.status === "succeeded") bucket.succeeded++;
if (run.status === "failed" || run.status === "timed_out" || run.status === "interrupted") {
bucket.failed++;
}
}
// Return sorted ascending by date
return Array.from(buckets.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, data]) => ({ date, ...data }));
}
/**
* Aggregate costs into daily buckets for chart consumption.
* Returns `days` entries sorted ascending by date.
* Days with no costs have {total:0}. Null amountUsd is treated as 0.
*/
export function aggregateCostsByDay(costs: CostRecord[], days: number): DailyCostData[] {
const now = new Date();
const buckets = new Map<string, number>();
// Initialize all day buckets with zero
for (let i = 0; i < days; i++) {
const date = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
const key = date.toISOString().slice(0, 10);
buckets.set(key, 0);
}
// Sum costs per day
for (const cost of costs) {
const day = cost.createdAt.slice(0, 10);
const current = buckets.get(day);
if (current === undefined) continue;
buckets.set(day, current + (cost.amountUsd ?? 0));
}
// Return sorted ascending by date
return Array.from(buckets.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, total]) => ({ date, total }));
}
/**
* Group runs by agent and return workload data sorted by run count descending.
* If more than maxAgents, keep top maxAgents and aggregate rest into "Others".
* Agent names longer than 10 chars are truncated with ellipsis.
*/
export function aggregateAgentWorkload(
runs: RunRecord[],
agents: AgentRecord[],
maxAgents: number = 8,
): AgentWorkloadData[] {
// Build name lookup from agents array
const nameMap = new Map<string, string>();
for (const agent of agents) {
nameMap.set(agent.id, agent.name);
}
// Count runs per agent
const counts = new Map<string, number>();
for (const run of runs) {
counts.set(run.agentId, (counts.get(run.agentId) ?? 0) + 1);
}
// Sort by count descending
const sorted = Array.from(counts.entries())
.sort(([, a], [, b]) => b - a);
const truncateName = (name: string): string =>
name.length > 10 ? name.slice(0, 10) + "..." : name;
// If within limit, return all
if (sorted.length <= maxAgents) {
return sorted.map(([agentId, runCount]) => ({
name: truncateName(nameMap.get(agentId) ?? agentId),
runs: runCount,
}));
}
// Split into top agents and "Others"
const top = sorted.slice(0, maxAgents);
const rest = sorted.slice(maxAgents);
const othersRuns = rest.reduce((sum, [, count]) => sum + count, 0);
const result: AgentWorkloadData[] = top.map(([agentId, runCount]) => ({
name: truncateName(nameMap.get(agentId) ?? agentId),
runs: runCount,
}));
result.push({ name: "Others", runs: othersRuns });
return result;
}
/**
* Format a YYYY-MM-DD date string as "MMM dd" (e.g., "Mar 15").
* Uses T00:00:00 suffix to avoid timezone shift.
*/
export function formatShortDate(dateStr: string): string {
const date = new Date(dateStr + "T00:00:00");
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}