@aion0/forge 0.5.29 → 0.5.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,26 +1,30 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useCallback } from 'react';
3
+ import { useState, useEffect, useCallback, useMemo } from 'react';
4
4
 
5
5
  interface UsageData {
6
- total: { input: number; output: number; cost: number; sessions: number; messages: number };
6
+ total: { input: number; output: number; cacheRead: number; cacheCreate: number; cost: number; sessions: number; messages: number };
7
7
  byProject: { name: string; input: number; output: number; cost: number; sessions: number }[];
8
8
  byModel: { model: string; input: number; output: number; cost: number; messages: number }[];
9
- byDay: { date: string; input: number; output: number; cost: number }[];
9
+ byDay: { date: string; input: number; output: number; cacheRead: number; cacheCreate: number; cost: number; messages: number }[];
10
10
  bySource: { source: string; input: number; output: number; cost: number; messages: number }[];
11
11
  }
12
12
 
13
13
  function formatTokens(n: number): string {
14
+ if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)}B`;
14
15
  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
15
16
  if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
16
17
  return String(n);
17
18
  }
18
19
 
19
20
  function formatCost(n: number): string {
21
+ if (n >= 1000) return `$${(n / 1000).toFixed(2)}k`;
22
+ if (n >= 100) return `$${n.toFixed(0)}`;
20
23
  return `$${n.toFixed(2)}`;
21
24
  }
22
25
 
23
- // Simple bar component
26
+ // ─── Horizontal bar ──────────────────────────────────────
27
+
24
28
  function Bar({ value, max, color }: { value: number; max: number; color: string }) {
25
29
  const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
26
30
  return (
@@ -30,6 +34,275 @@ function Bar({ value, max, color }: { value: number; max: number; color: string
30
34
  );
31
35
  }
32
36
 
37
+ // ─── Stacked bar for tokens breakdown ─────────────────────
38
+
39
+ function StackedBar({ input, output, cacheRead, cacheCreate }: { input: number; output: number; cacheRead: number; cacheCreate: number }) {
40
+ const total = input + output + cacheRead + cacheCreate;
41
+ if (total === 0) return <div className="h-2 bg-[var(--bg-tertiary)] rounded-full" />;
42
+ const inputPct = (input / total) * 100;
43
+ const outputPct = (output / total) * 100;
44
+ const cacheReadPct = (cacheRead / total) * 100;
45
+ const cacheCreatePct = (cacheCreate / total) * 100;
46
+ return (
47
+ <div className="h-2 bg-[var(--bg-tertiary)] rounded-full overflow-hidden flex">
48
+ <div className="h-full bg-blue-500" style={{ width: `${inputPct}%` }} title={`Input: ${formatTokens(input)}`} />
49
+ <div className="h-full bg-green-500" style={{ width: `${outputPct}%` }} title={`Output: ${formatTokens(output)}`} />
50
+ <div className="h-full bg-purple-500" style={{ width: `${cacheReadPct}%` }} title={`Cache read: ${formatTokens(cacheRead)}`} />
51
+ <div className="h-full bg-orange-500" style={{ width: `${cacheCreatePct}%` }} title={`Cache create: ${formatTokens(cacheCreate)}`} />
52
+ </div>
53
+ );
54
+ }
55
+
56
+ // ─── Pie/donut chart using SVG ─────────────────────────────
57
+
58
+ interface PieSlice { label: string; value: number; color: string }
59
+
60
+ function DonutChart({ data, size = 140 }: { data: PieSlice[]; size?: number }) {
61
+ const total = data.reduce((s, d) => s + d.value, 0);
62
+ if (total === 0) return <div className="text-[10px] text-[var(--text-secondary)]">No data</div>;
63
+
64
+ const radius = size / 2 - 4;
65
+ const innerRadius = radius * 0.6;
66
+ const cx = size / 2, cy = size / 2;
67
+
68
+ let startAngle = -Math.PI / 2; // start at top
69
+ const slices = data.map(slice => {
70
+ const fraction = slice.value / total;
71
+ const endAngle = startAngle + fraction * 2 * Math.PI;
72
+ const x1 = cx + radius * Math.cos(startAngle);
73
+ const y1 = cy + radius * Math.sin(startAngle);
74
+ const x2 = cx + radius * Math.cos(endAngle);
75
+ const y2 = cy + radius * Math.sin(endAngle);
76
+ const x3 = cx + innerRadius * Math.cos(endAngle);
77
+ const y3 = cy + innerRadius * Math.sin(endAngle);
78
+ const x4 = cx + innerRadius * Math.cos(startAngle);
79
+ const y4 = cy + innerRadius * Math.sin(startAngle);
80
+ const largeArc = fraction > 0.5 ? 1 : 0;
81
+ const d = `M ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} L ${x3} ${y3} A ${innerRadius} ${innerRadius} 0 ${largeArc} 0 ${x4} ${y4} Z`;
82
+ startAngle = endAngle;
83
+ return { d, color: slice.color, label: slice.label, value: slice.value, pct: fraction * 100 };
84
+ });
85
+
86
+ return (
87
+ <div className="flex items-center gap-3">
88
+ <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
89
+ {slices.map((s, i) => (
90
+ <path key={i} d={s.d} fill={s.color}>
91
+ <title>{`${s.label}: ${formatCost(s.value)} (${s.pct.toFixed(1)}%)`}</title>
92
+ </path>
93
+ ))}
94
+ <text x={cx} y={cy - 4} textAnchor="middle" className="fill-[var(--text-primary)] text-[11px] font-bold">
95
+ {formatCost(total)}
96
+ </text>
97
+ <text x={cx} y={cy + 10} textAnchor="middle" className="fill-[var(--text-secondary)] text-[8px]">
98
+ total
99
+ </text>
100
+ </svg>
101
+ <div className="flex-1 space-y-1">
102
+ {slices.map((s, i) => (
103
+ <div key={i} className="flex items-center gap-1.5 text-[10px]">
104
+ <span className="w-2 h-2 rounded-sm shrink-0" style={{ background: s.color }} />
105
+ <span className="text-[var(--text-primary)] truncate flex-1">{s.label}</span>
106
+ <span className="text-[var(--text-secondary)] text-[9px] w-10 text-right">{s.pct.toFixed(1)}%</span>
107
+ <span className="text-[var(--text-primary)] w-12 text-right font-mono">{formatCost(s.value)}</span>
108
+ </div>
109
+ ))}
110
+ </div>
111
+ </div>
112
+ );
113
+ }
114
+
115
+ // ─── Line chart for trends ────────────────────────────────
116
+
117
+ function LineChart({ data, width = 520, height = 80 }: { data: { date: string; cost: number; messages: number }[]; width?: number; height?: number }) {
118
+ if (data.length === 0) return <div className="text-[10px] text-[var(--text-secondary)] py-6 text-center">No data</div>;
119
+
120
+ // Reverse so earliest is on left
121
+ const points = [...data].reverse();
122
+ const padding = { top: 10, right: 10, bottom: 22, left: 40 };
123
+ const chartW = width - padding.left - padding.right;
124
+ const chartH = height - padding.top - padding.bottom;
125
+
126
+ const maxCost = Math.max(...points.map(p => p.cost), 0.001);
127
+ const maxMsgs = Math.max(...points.map(p => p.messages), 1);
128
+
129
+ const xStep = points.length > 1 ? chartW / (points.length - 1) : 0;
130
+ const pathCost = points.map((p, i) => {
131
+ const x = padding.left + i * xStep;
132
+ const y = padding.top + chartH - (p.cost / maxCost) * chartH;
133
+ return `${i === 0 ? 'M' : 'L'} ${x} ${y}`;
134
+ }).join(' ');
135
+
136
+ const areaCost = pathCost + ` L ${padding.left + (points.length - 1) * xStep} ${padding.top + chartH} L ${padding.left} ${padding.top + chartH} Z`;
137
+
138
+ // Y axis ticks (0, 50%, 100%)
139
+ const yTicks = [0, 0.5, 1];
140
+
141
+ return (
142
+ <svg width="100%" viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="xMidYMid meet">
143
+ {/* Grid lines */}
144
+ {yTicks.map((t, i) => {
145
+ const y = padding.top + chartH * (1 - t);
146
+ return (
147
+ <g key={i}>
148
+ <line x1={padding.left} y1={y} x2={padding.left + chartW} y2={y}
149
+ stroke="var(--border)" strokeWidth="0.5" strokeDasharray="2 2" />
150
+ <text x={padding.left - 4} y={y + 3} textAnchor="end" className="fill-[var(--text-secondary)] text-[8px]">
151
+ {formatCost(maxCost * t)}
152
+ </text>
153
+ </g>
154
+ );
155
+ })}
156
+
157
+ {/* Area fill */}
158
+ <path d={areaCost} fill="var(--accent)" opacity="0.15" />
159
+ {/* Line */}
160
+ <path d={pathCost} fill="none" stroke="var(--accent)" strokeWidth="1.5" />
161
+
162
+ {/* Points + labels (only a few to avoid overlap) */}
163
+ {points.map((p, i) => {
164
+ const x = padding.left + i * xStep;
165
+ const y = padding.top + chartH - (p.cost / maxCost) * chartH;
166
+ const showLabel = points.length <= 10 || i % Math.ceil(points.length / 7) === 0 || i === points.length - 1;
167
+ return (
168
+ <g key={p.date}>
169
+ <circle cx={x} cy={y} r="2" fill="var(--accent)" stroke="var(--bg-primary)" strokeWidth="0.5" />
170
+ {showLabel && (
171
+ <text x={x} y={height - 6} textAnchor="middle" className="fill-[var(--text-secondary)] text-[8px]">
172
+ {p.date.slice(5)}
173
+ </text>
174
+ )}
175
+ </g>
176
+ );
177
+ })}
178
+ </svg>
179
+ );
180
+ }
181
+
182
+ // ─── Heatmap by day (grid) ────────────────────────────────
183
+
184
+ function Heatmap({ data, days: numDays = 90 }: { data: { date: string; cost: number }[]; days?: number }) {
185
+ if (data.length === 0) return null;
186
+ const max = Math.max(...data.map(d => d.cost), 0.01);
187
+
188
+ const costMap = new Map(data.map(d => [d.date, d.cost]));
189
+
190
+ // Build last N days
191
+ const days: { date: string; cost: number; dow: number }[] = [];
192
+ const today = new Date();
193
+ for (let i = numDays - 1; i >= 0; i--) {
194
+ const d = new Date(today);
195
+ d.setDate(today.getDate() - i);
196
+ const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
197
+ days.push({ date: dateStr, cost: costMap.get(dateStr) || 0, dow: d.getDay() });
198
+ }
199
+
200
+ const intensity = (c: number) => {
201
+ if (c === 0) return 0;
202
+ return Math.min(4, Math.ceil((c / max) * 4));
203
+ };
204
+
205
+ const bgClasses = [
206
+ 'bg-[var(--bg-tertiary)]',
207
+ 'bg-blue-900/40',
208
+ 'bg-blue-700/60',
209
+ 'bg-blue-500/80',
210
+ 'bg-blue-400',
211
+ ];
212
+
213
+ // Organize by week (columns) × weekday (rows), GitHub-style
214
+ // Start with padding for the first week
215
+ const firstDow = days[0].dow;
216
+ const weeks: (typeof days[0] | null)[][] = [];
217
+ let currentWeek: (typeof days[0] | null)[] = Array(firstDow).fill(null);
218
+ for (const d of days) {
219
+ currentWeek.push(d);
220
+ if (currentWeek.length === 7) {
221
+ weeks.push(currentWeek);
222
+ currentWeek = [];
223
+ }
224
+ }
225
+ if (currentWeek.length > 0) {
226
+ while (currentWeek.length < 7) currentWeek.push(null);
227
+ weeks.push(currentWeek);
228
+ }
229
+
230
+ const dayLabels = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
231
+
232
+ return (
233
+ <div className="flex gap-1">
234
+ {/* Weekday labels */}
235
+ <div className="flex flex-col gap-[2px] pt-0.5">
236
+ {dayLabels.map((l, i) => (
237
+ <div key={i} className="text-[7px] text-[var(--text-secondary)] h-[11px] leading-[11px]">
238
+ {i % 2 === 1 ? l : ''}
239
+ </div>
240
+ ))}
241
+ </div>
242
+ {/* Week columns */}
243
+ <div className="flex gap-[2px] overflow-x-auto">
244
+ {weeks.map((week, wi) => (
245
+ <div key={wi} className="flex flex-col gap-[2px]">
246
+ {week.map((day, di) => (
247
+ day ? (
248
+ <div
249
+ key={di}
250
+ className={`w-[11px] h-[11px] rounded-sm ${bgClasses[intensity(day.cost)]}`}
251
+ title={`${day.date}: ${formatCost(day.cost)}`}
252
+ />
253
+ ) : (
254
+ <div key={di} className="w-[11px] h-[11px]" />
255
+ )
256
+ ))}
257
+ </div>
258
+ ))}
259
+ </div>
260
+ </div>
261
+ );
262
+ }
263
+
264
+ // ─── Weekday cost distribution ──────────────────────────
265
+
266
+ function WeekdayChart({ data }: { data: { date: string; cost: number }[] }) {
267
+ if (data.length === 0) return null;
268
+ const byDow: number[] = Array(7).fill(0);
269
+ const countByDow: number[] = Array(7).fill(0);
270
+ for (const d of data) {
271
+ const date = new Date(d.date);
272
+ const dow = date.getDay();
273
+ byDow[dow] += d.cost;
274
+ countByDow[dow]++;
275
+ }
276
+ const avgByDow = byDow.map((sum, i) => countByDow[i] > 0 ? sum / countByDow[i] : 0);
277
+ const max = Math.max(...avgByDow, 0.01);
278
+ const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
279
+
280
+ return (
281
+ <div className="flex items-end gap-1 h-16">
282
+ {avgByDow.map((v, i) => {
283
+ const pct = (v / max) * 100;
284
+ const isWeekend = i === 0 || i === 6;
285
+ return (
286
+ <div key={i} className="flex-1 flex flex-col items-center gap-0.5">
287
+ <div className="w-full flex-1 flex items-end">
288
+ <div
289
+ className={`w-full rounded-t ${isWeekend ? 'bg-orange-500/60' : 'bg-blue-500/60'}`}
290
+ style={{ height: `${Math.max(pct, 2)}%` }}
291
+ title={`${labels[i]}: ${formatCost(v)} avg`}
292
+ />
293
+ </div>
294
+ <div className="text-[8px] text-[var(--text-secondary)]">{labels[i][0]}</div>
295
+ </div>
296
+ );
297
+ })}
298
+ </div>
299
+ );
300
+ }
301
+
302
+ // ─── Main component ────────────────────────────────────────
303
+
304
+ const MODEL_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'];
305
+
33
306
  export default function UsagePanel() {
34
307
  const [data, setData] = useState<UsageData | null>(null);
35
308
  const [days, setDays] = useState(7);
@@ -57,16 +330,51 @@ export default function UsagePanel() {
57
330
  setScanning(false);
58
331
  };
59
332
 
333
+ // Calculate averages and trends
334
+ const stats = useMemo(() => {
335
+ if (!data) return null;
336
+ const byDay = data.byDay;
337
+ const avgDaily = byDay.length > 0 ? data.total.cost / byDay.length : 0;
338
+ const avgPerSession = data.total.sessions > 0 ? data.total.cost / data.total.sessions : 0;
339
+ const avgPerMsg = data.total.messages > 0 ? data.total.cost / data.total.messages : 0;
340
+
341
+ // Trend: compare last half vs first half
342
+ let trend = 0;
343
+ if (byDay.length >= 4) {
344
+ const mid = Math.floor(byDay.length / 2);
345
+ const recent = byDay.slice(0, mid).reduce((s, d) => s + d.cost, 0);
346
+ const earlier = byDay.slice(mid).reduce((s, d) => s + d.cost, 0);
347
+ trend = earlier > 0 ? ((recent - earlier) / earlier) * 100 : 0;
348
+ }
349
+
350
+ // Cache efficiency
351
+ const totalInput = data.total.input + data.total.cacheRead;
352
+ const cacheHitRate = totalInput > 0 ? (data.total.cacheRead / totalInput) * 100 : 0;
353
+
354
+ return { avgDaily, avgPerSession, avgPerMsg, trend, cacheHitRate };
355
+ }, [data]);
356
+
60
357
  if (loading && !data) {
61
358
  return <div className="flex-1 flex items-center justify-center text-[var(--text-secondary)] text-xs">Loading usage data...</div>;
62
359
  }
63
360
 
64
- if (!data) {
361
+ if (!data || !stats) {
65
362
  return <div className="flex-1 flex items-center justify-center text-[var(--text-secondary)] text-xs">Failed to load usage data</div>;
66
363
  }
67
364
 
68
365
  const maxProjectCost = data.byProject.length > 0 ? data.byProject[0].cost : 1;
69
- const maxDayCost = data.byDay.length > 0 ? Math.max(...data.byDay.map(d => d.cost)) : 1;
366
+
367
+ // Prepare pie chart data
368
+ const modelPie: PieSlice[] = data.byModel.slice(0, 6).map((m, i) => ({
369
+ label: m.model.replace('claude-', ''),
370
+ value: m.cost,
371
+ color: MODEL_COLORS[i % MODEL_COLORS.length],
372
+ }));
373
+ const sourcePie: PieSlice[] = data.bySource.slice(0, 6).map((s, i) => ({
374
+ label: s.source,
375
+ value: s.cost,
376
+ color: MODEL_COLORS[i % MODEL_COLORS.length],
377
+ }));
70
378
 
71
379
  return (
72
380
  <div className="flex-1 flex flex-col min-h-0 overflow-y-auto">
@@ -93,54 +401,123 @@ export default function UsagePanel() {
93
401
  </button>
94
402
  </div>
95
403
 
96
- <div className="p-4 space-y-6">
97
- {/* Summary cards */}
404
+ <div className="p-4 space-y-5">
405
+ {/* ─── Summary cards ──────────────────────────────── */}
98
406
  <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
99
407
  <div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-3">
100
408
  <div className="text-[9px] text-[var(--text-secondary)] uppercase">Total Cost</div>
101
409
  <div className="text-lg font-bold text-[var(--text-primary)]">{formatCost(data.total.cost)}</div>
410
+ {stats.trend !== 0 && (
411
+ <div className={`text-[9px] ${stats.trend > 0 ? 'text-red-400' : 'text-green-400'}`}>
412
+ {stats.trend > 0 ? '↑' : '↓'} {Math.abs(stats.trend).toFixed(0)}% vs prev period
413
+ </div>
414
+ )}
102
415
  </div>
103
416
  <div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-3">
104
- <div className="text-[9px] text-[var(--text-secondary)] uppercase">Input Tokens</div>
105
- <div className="text-lg font-bold text-[var(--text-primary)]">{formatTokens(data.total.input)}</div>
417
+ <div className="text-[9px] text-[var(--text-secondary)] uppercase">Daily Avg</div>
418
+ <div className="text-lg font-bold text-[var(--text-primary)]">{formatCost(stats.avgDaily)}</div>
419
+ <div className="text-[9px] text-[var(--text-secondary)]">per day</div>
106
420
  </div>
107
421
  <div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-3">
108
- <div className="text-[9px] text-[var(--text-secondary)] uppercase">Output Tokens</div>
109
- <div className="text-lg font-bold text-[var(--text-primary)]">{formatTokens(data.total.output)}</div>
422
+ <div className="text-[9px] text-[var(--text-secondary)] uppercase">Tokens</div>
423
+ <div className="text-lg font-bold text-[var(--text-primary)]">{formatTokens(data.total.input + data.total.output + data.total.cacheRead)}</div>
424
+ <div className="text-[9px] text-[var(--text-secondary)]">
425
+ {formatTokens(data.total.input)} in · {formatTokens(data.total.output)} out
426
+ </div>
110
427
  </div>
111
428
  <div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-3">
112
- <div className="text-[9px] text-[var(--text-secondary)] uppercase">Sessions</div>
113
- <div className="text-lg font-bold text-[var(--text-primary)]">{data.total.sessions}</div>
114
- <div className="text-[9px] text-[var(--text-secondary)]">{data.total.messages} messages</div>
429
+ <div className="text-[9px] text-[var(--text-secondary)] uppercase">Cache Hit</div>
430
+ <div className="text-lg font-bold text-[var(--text-primary)]">{stats.cacheHitRate.toFixed(0)}%</div>
431
+ <div className="text-[9px] text-[var(--text-secondary)]">{formatTokens(data.total.cacheRead)} cached</div>
432
+ </div>
433
+ </div>
434
+
435
+ {/* ─── Token breakdown stacked bar ──────────────── */}
436
+ <div>
437
+ <div className="flex items-center justify-between mb-1.5">
438
+ <h3 className="text-[11px] font-semibold text-[var(--text-secondary)] uppercase">Token Mix</h3>
439
+ <div className="flex items-center gap-3 text-[9px]">
440
+ <span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm bg-blue-500"></span>Input</span>
441
+ <span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm bg-green-500"></span>Output</span>
442
+ <span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm bg-purple-500"></span>Cache R</span>
443
+ <span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm bg-orange-500"></span>Cache W</span>
444
+ </div>
115
445
  </div>
446
+ <StackedBar
447
+ input={data.total.input}
448
+ output={data.total.output}
449
+ cacheRead={data.total.cacheRead}
450
+ cacheCreate={data.total.cacheCreate}
451
+ />
116
452
  </div>
117
453
 
118
- {/* By Day bar chart */}
454
+ {/* ─── Daily trend line chart ───────────────────── */}
119
455
  {data.byDay.length > 0 && (
120
456
  <div>
121
- <h3 className="text-[11px] font-semibold text-[var(--text-secondary)] uppercase mb-2">Daily Cost</h3>
122
- <div className="space-y-1">
123
- {data.byDay.slice(0, 14).reverse().map(d => (
124
- <div key={d.date} className="flex items-center gap-2 text-[10px]">
125
- <span className="text-[var(--text-secondary)] w-16 shrink-0">{d.date.slice(5)}</span>
126
- <Bar value={d.cost} max={maxDayCost} color="bg-[var(--accent)]" />
127
- <span className="text-[var(--text-primary)] w-16 text-right shrink-0">{formatCost(d.cost)}</span>
128
- </div>
129
- ))}
457
+ <h3 className="text-[11px] font-semibold text-[var(--text-secondary)] uppercase mb-2">Cost Trend</h3>
458
+ <div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-3">
459
+ <LineChart data={data.byDay} />
130
460
  </div>
131
461
  </div>
132
462
  )}
133
463
 
134
- {/* By Project */}
464
+ {/* ─── Activity heatmap (90 days) + weekday ─────── */}
465
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-3">
466
+ <div className="md:col-span-2">
467
+ <div className="flex items-center justify-between mb-2">
468
+ <h3 className="text-[11px] font-semibold text-[var(--text-secondary)] uppercase">Activity (last 90 days)</h3>
469
+ <div className="flex items-center gap-1 text-[8px] text-[var(--text-secondary)]">
470
+ <span>less</span>
471
+ <span className="w-2 h-2 rounded-sm bg-[var(--bg-tertiary)]"></span>
472
+ <span className="w-2 h-2 rounded-sm bg-blue-900/40"></span>
473
+ <span className="w-2 h-2 rounded-sm bg-blue-700/60"></span>
474
+ <span className="w-2 h-2 rounded-sm bg-blue-500/80"></span>
475
+ <span className="w-2 h-2 rounded-sm bg-blue-400"></span>
476
+ <span>more</span>
477
+ </div>
478
+ </div>
479
+ <div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-3">
480
+ <Heatmap data={data.byDay} days={91} />
481
+ </div>
482
+ </div>
483
+ <div>
484
+ <h3 className="text-[11px] font-semibold text-[var(--text-secondary)] uppercase mb-2">Avg by Weekday</h3>
485
+ <div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-3">
486
+ <WeekdayChart data={data.byDay} />
487
+ </div>
488
+ </div>
489
+ </div>
490
+
491
+ {/* ─── Pie charts row ───────────────────────────── */}
492
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
493
+ {modelPie.length > 0 && (
494
+ <div>
495
+ <h3 className="text-[11px] font-semibold text-[var(--text-secondary)] uppercase mb-2">By Model</h3>
496
+ <div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-3">
497
+ <DonutChart data={modelPie} />
498
+ </div>
499
+ </div>
500
+ )}
501
+ {sourcePie.length > 0 && (
502
+ <div>
503
+ <h3 className="text-[11px] font-semibold text-[var(--text-secondary)] uppercase mb-2">By Source</h3>
504
+ <div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-3">
505
+ <DonutChart data={sourcePie} />
506
+ </div>
507
+ </div>
508
+ )}
509
+ </div>
510
+
511
+ {/* ─── By Project ──────────────────────────────── */}
135
512
  {data.byProject.length > 0 && (
136
513
  <div>
137
514
  <h3 className="text-[11px] font-semibold text-[var(--text-secondary)] uppercase mb-2">By Project</h3>
138
- <div className="space-y-1.5">
515
+ <div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-3 space-y-1.5">
139
516
  {data.byProject.map(p => (
140
517
  <div key={p.name} className="flex items-center gap-2 text-[10px]">
141
518
  <span className="text-[var(--text-primary)] w-28 truncate shrink-0" title={p.name}>{p.name}</span>
142
519
  <Bar value={p.cost} max={maxProjectCost} color="bg-blue-500" />
143
- <span className="text-[var(--text-primary)] w-16 text-right shrink-0">{formatCost(p.cost)}</span>
520
+ <span className="text-[var(--text-primary)] w-16 text-right shrink-0 font-mono">{formatCost(p.cost)}</span>
144
521
  <span className="text-[var(--text-secondary)] w-12 text-right shrink-0">{p.sessions}s</span>
145
522
  </div>
146
523
  ))}
@@ -148,11 +525,11 @@ export default function UsagePanel() {
148
525
  </div>
149
526
  )}
150
527
 
151
- {/* By Model */}
528
+ {/* ─── By Model detailed table ─────────────────── */}
152
529
  {data.byModel.length > 0 && (
153
530
  <div>
154
- <h3 className="text-[11px] font-semibold text-[var(--text-secondary)] uppercase mb-2">By Model</h3>
155
- <div className="border border-[var(--border)] rounded overflow-hidden">
531
+ <h3 className="text-[11px] font-semibold text-[var(--text-secondary)] uppercase mb-2">Model Details</h3>
532
+ <div className="border border-[var(--border)] rounded-lg overflow-hidden">
156
533
  <table className="w-full text-[10px]">
157
534
  <thead>
158
535
  <tr className="bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">
@@ -161,16 +538,20 @@ export default function UsagePanel() {
161
538
  <th className="text-right px-3 py-1.5">Output</th>
162
539
  <th className="text-right px-3 py-1.5">Cost</th>
163
540
  <th className="text-right px-3 py-1.5">Msgs</th>
541
+ <th className="text-right px-3 py-1.5">Avg/Msg</th>
164
542
  </tr>
165
543
  </thead>
166
544
  <tbody>
167
545
  {data.byModel.map(m => (
168
546
  <tr key={m.model} className="border-t border-[var(--border)]/30">
169
547
  <td className="px-3 py-1.5 text-[var(--text-primary)]">{m.model}</td>
170
- <td className="px-3 py-1.5 text-right text-[var(--text-secondary)]">{formatTokens(m.input)}</td>
171
- <td className="px-3 py-1.5 text-right text-[var(--text-secondary)]">{formatTokens(m.output)}</td>
172
- <td className="px-3 py-1.5 text-right text-[var(--text-primary)] font-medium">{formatCost(m.cost)}</td>
548
+ <td className="px-3 py-1.5 text-right text-[var(--text-secondary)] font-mono">{formatTokens(m.input)}</td>
549
+ <td className="px-3 py-1.5 text-right text-[var(--text-secondary)] font-mono">{formatTokens(m.output)}</td>
550
+ <td className="px-3 py-1.5 text-right text-[var(--text-primary)] font-medium font-mono">{formatCost(m.cost)}</td>
173
551
  <td className="px-3 py-1.5 text-right text-[var(--text-secondary)]">{m.messages}</td>
552
+ <td className="px-3 py-1.5 text-right text-[var(--text-secondary)] font-mono">
553
+ {m.messages > 0 ? `$${(m.cost / m.messages).toFixed(3)}` : '-'}
554
+ </td>
174
555
  </tr>
175
556
  ))}
176
557
  </tbody>
@@ -179,27 +560,28 @@ export default function UsagePanel() {
179
560
  </div>
180
561
  )}
181
562
 
182
- {/* By Source */}
183
- {data.bySource.length > 0 && (
184
- <div>
185
- <h3 className="text-[11px] font-semibold text-[var(--text-secondary)] uppercase mb-2">By Source</h3>
186
- <div className="flex gap-3 flex-wrap">
187
- {data.bySource.map(s => (
188
- <div key={s.source} className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg px-3 py-2 min-w-[120px]">
189
- <div className="text-[9px] text-[var(--text-secondary)] uppercase">{s.source}</div>
190
- <div className="text-sm font-bold text-[var(--text-primary)]">{formatCost(s.cost)}</div>
191
- <div className="text-[9px] text-[var(--text-secondary)]">{s.messages} msgs</div>
192
- </div>
193
- ))}
563
+ {/* ─── Summary stats ────────────────────────────── */}
564
+ <div className="grid grid-cols-3 gap-2 text-[10px]">
565
+ <div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded p-2">
566
+ <div className="text-[8px] text-[var(--text-secondary)] uppercase">Avg per session</div>
567
+ <div className="text-[12px] font-semibold text-[var(--text-primary)]">{formatCost(stats.avgPerSession)}</div>
568
+ </div>
569
+ <div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded p-2">
570
+ <div className="text-[8px] text-[var(--text-secondary)] uppercase">Avg per message</div>
571
+ <div className="text-[12px] font-semibold text-[var(--text-primary)]">${stats.avgPerMsg.toFixed(3)}</div>
572
+ </div>
573
+ <div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded p-2">
574
+ <div className="text-[8px] text-[var(--text-secondary)] uppercase">Sessions / day</div>
575
+ <div className="text-[12px] font-semibold text-[var(--text-primary)]">
576
+ {data.byDay.length > 0 ? (data.total.sessions / data.byDay.length).toFixed(1) : '0'}
194
577
  </div>
195
578
  </div>
196
- )}
579
+ </div>
197
580
 
198
581
  {/* Note */}
199
582
  <div className="text-[9px] text-[var(--text-secondary)] border-t border-[var(--border)] pt-3">
200
583
  Cost estimates based on API pricing (Opus: $15/$75 per M tokens, Sonnet: $3/$15).
201
- Actual cost may differ with Claude Max/Pro subscription.
202
- Daily breakdown groups by session completion date.
584
+ Cache reads are ~90% cheaper. Actual cost may differ with Claude Max/Pro subscription.
203
585
  </div>
204
586
  </div>
205
587
  </div>