Skip to content

Commit 0fd866c

Browse files
feat(dashboard): cost analytics panels — burn rate, token breakdown, cost by model (#3275)
Implements #3273 — Three new cost analytics panels for the Aegis dashboard. New components: BurnRateChart, TokenBreakdownChart, CostByModelChart. 15 new Vitest tests. Time range picker. Loading/empty/error states. Mock data pending real API (#3264).
1 parent 4b2f1b0 commit 0fd866c

7 files changed

Lines changed: 662 additions & 1 deletion

File tree

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* BurnRateChart.tsx — Session burn rate line chart.
3+
*
4+
* Shows cost velocity over time (USD on Y, date on X).
5+
* Part of issue #3273: Cost Analytics Panels.
6+
*/
7+
8+
import {
9+
LineChart,
10+
Line,
11+
XAxis,
12+
YAxis,
13+
Tooltip,
14+
CartesianGrid,
15+
} from 'recharts';
16+
import { formatCurrency } from '../../utils/formatNumber';
17+
import { formatDateShort } from '../../utils/formatDate';
18+
import { ChartFrame } from '../shared/ChartFrame';
19+
20+
export interface BurnRateDataPoint {
21+
date: string;
22+
cost: number;
23+
}
24+
25+
export interface BurnRateChartProps {
26+
data?: BurnRateDataPoint[];
27+
loading?: boolean;
28+
className?: string;
29+
}
30+
31+
function generateMockData(days: number): BurnRateDataPoint[] {
32+
const today = new Date();
33+
const data: BurnRateDataPoint[] = [];
34+
for (let i = days - 1; i >= 0; i--) {
35+
const d = new Date(today);
36+
d.setDate(d.getDate() - i);
37+
const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
38+
// Upward trend with noise
39+
const base = 2 + (days - i) * 0.15;
40+
const noise = (Math.sin(i * 1.7) * 0.8 + Math.cos(i * 0.3) * 0.5);
41+
data.push({ date: dateStr, cost: Math.max(0.1, base + noise) });
42+
}
43+
return data;
44+
}
45+
46+
function CustomTooltip({ active, payload, label }: {
47+
active?: boolean;
48+
payload?: Array<{ value: number }>;
49+
label?: string;
50+
}) {
51+
if (!active || !payload?.length) return null;
52+
return (
53+
<div className="rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-surface)] p-3 shadow-xl">
54+
<p className="mb-1 text-xs font-medium text-[var(--color-text-primary)]">
55+
{label ? formatDateShort(label) : ''}
56+
</p>
57+
<p className="text-sm font-mono font-medium text-[var(--color-accent-cyan)]">
58+
{formatCurrency(payload[0].value)}
59+
</p>
60+
</div>
61+
);
62+
}
63+
64+
export function BurnRateChart({ data, loading = false, className = '' }: BurnRateChartProps) {
65+
const chartData = data ?? generateMockData(30);
66+
67+
if (loading) {
68+
return (
69+
<section
70+
className={`rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-surface-strong)] p-5 ${className}`}
71+
aria-label="Burn rate chart loading"
72+
>
73+
<h3 className="mb-4 text-lg font-medium text-[var(--color-text-primary)]">
74+
Session Burn Rate
75+
</h3>
76+
<div className="flex h-64 items-center justify-center">
77+
<div className="h-4 w-4 animate-spin rounded-full border-2 border-[var(--color-accent-cyan)] border-t-transparent" />
78+
</div>
79+
</section>
80+
);
81+
}
82+
83+
if (chartData.length === 0) {
84+
return (
85+
<section
86+
className={`rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-surface-strong)] p-5 ${className}`}
87+
aria-label="Burn rate chart"
88+
>
89+
<h3 className="mb-4 text-lg font-medium text-[var(--color-text-primary)]">
90+
Session Burn Rate
91+
</h3>
92+
<p className="py-12 text-center text-sm text-[var(--color-text-muted)]">
93+
No cost data available yet. Start a session to begin tracking.
94+
</p>
95+
</section>
96+
);
97+
}
98+
99+
return (
100+
<section
101+
className={`rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-surface-strong)] p-5 ${className}`}
102+
aria-label="Session burn rate chart"
103+
>
104+
<div className="mb-4 flex items-center justify-between">
105+
<h3 className="text-lg font-medium text-[var(--color-text-primary)]">
106+
Session Burn Rate
107+
</h3>
108+
<span className="text-xs text-[var(--color-text-muted)]">
109+
Cost velocity over time
110+
</span>
111+
</div>
112+
<ChartFrame className="h-72 min-w-0" label="Burn rate chart loading">
113+
{({ width, height }) => (
114+
<LineChart width={width} height={height} data={chartData}>
115+
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-void-lighter)" />
116+
<XAxis
117+
dataKey="date"
118+
tickFormatter={formatDateShort}
119+
tick={{ fill: 'var(--color-text-muted)', fontSize: 11 }}
120+
stroke="var(--color-void-lighter)"
121+
/>
122+
<YAxis
123+
tickFormatter={(v: number) => `$${v.toFixed(2)}`}
124+
tick={{ fill: 'var(--color-text-muted)', fontSize: 11 }}
125+
stroke="var(--color-void-lighter)"
126+
/>
127+
<Tooltip content={<CustomTooltip />} />
128+
<Line
129+
type="monotone"
130+
dataKey="cost"
131+
stroke="var(--color-accent-cyan)"
132+
strokeWidth={2}
133+
dot={{ r: 3, fill: 'var(--color-accent-cyan)' }}
134+
activeDot={{ r: 5, fill: 'var(--color-accent-cyan)' }}
135+
animationDuration={500}
136+
/>
137+
</LineChart>
138+
)}
139+
</ChartFrame>
140+
</section>
141+
);
142+
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/**
2+
* CostByModelChart.tsx — Horizontal bar chart showing cost per model.
3+
*
4+
* Displays total USD grouped by model with color coding.
5+
* Part of issue #3273: Cost Analytics Panels.
6+
*/
7+
8+
import {
9+
BarChart,
10+
Bar,
11+
XAxis,
12+
YAxis,
13+
Tooltip,
14+
CartesianGrid,
15+
Cell,
16+
} from 'recharts';
17+
import { formatCurrency } from '../../utils/formatNumber';
18+
import { ChartFrame } from '../shared/ChartFrame';
19+
20+
export interface CostByModelDataPoint {
21+
model: string;
22+
cost: number;
23+
}
24+
25+
export interface CostByModelChartProps {
26+
data?: CostByModelDataPoint[];
27+
loading?: boolean;
28+
className?: string;
29+
}
30+
31+
const MODEL_COLORS: Record<string, string> = {
32+
'claude-opus-4.7': 'var(--color-accent-purple)',
33+
'claude-sonnet-4.6': 'var(--color-accent-cyan)',
34+
'claude-haiku-4.5': 'var(--color-success)',
35+
'gpt-5.4': 'var(--color-warning)',
36+
'gpt-4.1': 'var(--color-info)',
37+
other: 'var(--color-text-muted)',
38+
};
39+
40+
const MOCK_DATA: CostByModelDataPoint[] = [
41+
{ model: 'claude-opus-4.7', cost: 47.82 },
42+
{ model: 'claude-sonnet-4.6', cost: 28.15 },
43+
{ model: 'claude-haiku-4.5', cost: 3.40 },
44+
{ model: 'gpt-5.4', cost: 12.67 },
45+
{ model: 'gpt-4.1', cost: 5.91 },
46+
];
47+
48+
function CustomTooltip({ active, payload }: {
49+
active?: boolean;
50+
payload?: Array<{ payload: CostByModelDataPoint }>;
51+
}) {
52+
if (!active || !payload?.length) return null;
53+
const point = payload[0].payload;
54+
return (
55+
<div className="rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-surface)] p-3 shadow-xl">
56+
<p className="mb-1 text-xs font-mono text-[var(--color-text-muted)]">
57+
{point.model}
58+
</p>
59+
<p className="text-sm font-mono font-medium text-[var(--color-text-primary)]">
60+
{formatCurrency(point.cost)}
61+
</p>
62+
</div>
63+
);
64+
}
65+
66+
export function CostByModelChart({ data, loading = false, className = '' }: CostByModelChartProps) {
67+
const chartData = data ?? MOCK_DATA;
68+
69+
if (loading) {
70+
return (
71+
<section
72+
className={`rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-surface-strong)] p-5 ${className}`}
73+
aria-label="Cost by model chart loading"
74+
>
75+
<h3 className="mb-4 text-lg font-medium text-[var(--color-text-primary)]">
76+
Cost by Model
77+
</h3>
78+
<div className="flex h-64 items-center justify-center">
79+
<div className="h-4 w-4 animate-spin rounded-full border-2 border-[var(--color-accent-purple)] border-t-transparent" />
80+
</div>
81+
</section>
82+
);
83+
}
84+
85+
if (chartData.length === 0) {
86+
return (
87+
<section
88+
className={`rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-surface-strong)] p-5 ${className}`}
89+
aria-label="Cost by model chart"
90+
>
91+
<h3 className="mb-4 text-lg font-medium text-[var(--color-text-primary)]">
92+
Cost by Model
93+
</h3>
94+
<p className="py-12 text-center text-sm text-[var(--color-text-muted)]">
95+
No model cost data available yet.
96+
</p>
97+
</section>
98+
);
99+
}
100+
101+
return (
102+
<section
103+
className={`rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-surface-strong)] p-5 ${className}`}
104+
aria-label="Cost by model chart"
105+
>
106+
<div className="mb-4 flex items-center justify-between">
107+
<h3 className="text-lg font-medium text-[var(--color-text-primary)]">
108+
Cost by Model
109+
</h3>
110+
<span className="text-xs text-[var(--color-text-muted)]">
111+
Total spend per model
112+
</span>
113+
</div>
114+
115+
{/* Legend */}
116+
<div className="mb-3 flex flex-wrap gap-3">
117+
{chartData.map((entry) => (
118+
<div key={entry.model} className="flex items-center gap-1.5">
119+
<div
120+
className="h-2.5 w-2.5 rounded-sm"
121+
style={{ backgroundColor: MODEL_COLORS[entry.model] ?? MODEL_COLORS.other }}
122+
/>
123+
<span className="text-xs text-[var(--color-text-muted)]">{entry.model}</span>
124+
</div>
125+
))}
126+
</div>
127+
128+
<ChartFrame className="h-64 min-w-0" label="Cost by model chart loading">
129+
{({ width, height }) => (
130+
<BarChart width={width} height={height} data={chartData} layout="vertical" margin={{ left: 20 }}>
131+
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-void-lighter)" horizontal={false} />
132+
<XAxis
133+
type="number"
134+
tickFormatter={(v: number) => `$${v.toFixed(0)}`}
135+
tick={{ fill: 'var(--color-text-muted)', fontSize: 11 }}
136+
stroke="var(--color-void-lighter)"
137+
/>
138+
<YAxis
139+
type="category"
140+
dataKey="model"
141+
tick={{ fill: 'var(--color-text-muted)', fontSize: 11 }}
142+
stroke="var(--color-void-lighter)"
143+
width={120}
144+
/>
145+
<Tooltip content={<CustomTooltip />} />
146+
<Bar dataKey="cost" radius={[0, 4, 4, 0]} animationDuration={500}>
147+
{chartData.map((entry) => (
148+
<Cell
149+
key={entry.model}
150+
fill={MODEL_COLORS[entry.model] ?? MODEL_COLORS.other}
151+
/>
152+
))}
153+
</Bar>
154+
</BarChart>
155+
)}
156+
</ChartFrame>
157+
</section>
158+
);
159+
}

0 commit comments

Comments
 (0)