claude-usage-dashboard 1.3.4 → 1.3.6

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,357 +1,357 @@
1
- // d3 is loaded as a global via <script> tag in index.html
2
-
3
- export function renderTokenTrend(container, data, opts = {}) {
4
- const showDollars = opts.yAxis === 'dollars';
5
- const el = d3.select(container);
6
- el.selectAll('*').remove();
7
-
8
- if (!data.buckets || data.buckets.length === 0) {
9
- el.append('p').style('color', '#64748b').text('No data for selected range');
10
- return;
11
- }
12
-
13
- const margin = { top: 20, right: 30, bottom: 60, left: 60 };
14
- const width = container.clientWidth - margin.left - margin.right;
15
- const height = 250 - margin.top - margin.bottom;
16
-
17
- const svg = el.append('svg')
18
- .attr('width', width + margin.left + margin.right)
19
- .attr('height', height + margin.top + margin.bottom)
20
- .append('g')
21
- .attr('transform', `translate(${margin.left},${margin.top})`);
22
-
23
- const emptyBucket = (time) => ({ time, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0, estimated_cost_usd: 0 });
24
-
25
- // Fill in missing time slots so blank periods are visible
26
- const bucketMap = new Map(data.buckets.map(b => [b.time, b]));
27
- let allKeys;
28
- if (data.granularity === 'hourly') {
29
- allKeys = [];
30
- const first = data.buckets[0].time; // e.g. "2026-03-15T08:00"
31
- const last = data.buckets[data.buckets.length - 1].time;
32
- const pad = n => String(n).padStart(2, '0');
33
- const cur = new Date(first.replace('T', ' ').replace(/:00$/, ':00:00'));
34
- const end = new Date(last.replace('T', ' ').replace(/:00$/, ':00:00'));
35
- while (cur <= end) {
36
- const key = `${cur.getFullYear()}-${pad(cur.getMonth() + 1)}-${pad(cur.getDate())}T${pad(cur.getHours())}:00`;
37
- allKeys.push(key);
38
- cur.setHours(cur.getHours() + 1);
39
- }
40
- } else if (data.granularity === 'daily') {
41
- allKeys = [];
42
- const pad = n => String(n).padStart(2, '0');
43
- const cur = new Date(data.buckets[0].time + 'T00:00:00');
44
- const end = new Date(data.buckets[data.buckets.length - 1].time + 'T00:00:00');
45
- while (cur <= end) {
46
- allKeys.push(`${cur.getFullYear()}-${pad(cur.getMonth() + 1)}-${pad(cur.getDate())}`);
47
- cur.setDate(cur.getDate() + 1);
48
- }
49
- } else {
50
- allKeys = data.buckets.map(b => b.time);
51
- }
52
- const buckets = allKeys.map(k => bucketMap.get(k) || emptyBucket(k));
53
-
54
- const x = d3.scaleBand()
55
- .domain(buckets.map(d => d.time))
56
- .range([0, width])
57
- .padding(0.1);
58
-
59
- // Helper to get total height for each bucket
60
- const totalOf = d => d.input_tokens + d.output_tokens + (d.cache_read_tokens || 0) + (d.cache_creation_tokens || 0);
61
- const costOf = d => d.estimated_cost_usd || 0;
62
- const valueOf = showDollars ? costOf : totalOf;
63
-
64
- const maxVal = d3.max(buckets, valueOf) || 1;
65
- const y = d3.scaleLinear().domain([0, maxVal * 1.1]).range([height, 0]);
66
-
67
- const maxTicks = data.granularity === 'hourly' ? 12 : 10;
68
- const tickVals = x.domain().filter((_, i) => i % Math.ceil(buckets.length / maxTicks) === 0);
69
- const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
70
- const formatTick = (t) => {
71
- // Hourly: "2026-03-15T08:00" → "Mar 15 8AM"
72
- const h = t.match(/^\d{4}-(\d{2})-(\d{2})T(\d{2}):00$/);
73
- if (h) {
74
- const hr = parseInt(h[3], 10);
75
- const ampm = hr === 0 ? '12AM' : hr < 12 ? `${hr}AM` : hr === 12 ? '12PM' : `${hr - 12}PM`;
76
- return `${months[parseInt(h[1], 10) - 1]} ${parseInt(h[2], 10)} ${ampm}`;
77
- }
78
- // Daily: "2026-03-08" → "Mar 8"
79
- const m = t.match(/^\d{4}-(\d{2})-(\d{2})$/);
80
- if (m) {
81
- return `${months[parseInt(m[1], 10) - 1]} ${parseInt(m[2], 10)}`;
82
- }
83
- return t;
84
- };
85
- const xAxis = svg.append('g')
86
- .attr('transform', `translate(0,${height})`)
87
- .call(d3.axisBottom(x).tickValues(tickVals).tickFormat(formatTick));
88
- xAxis.selectAll('text').style('fill', '#64748b').style('font-size', '10px')
89
- .attr('transform', 'rotate(-45)').attr('text-anchor', 'end');
90
- xAxis.selectAll('line, path').style('stroke', '#334155');
91
-
92
- const yAxisFmt = showDollars ? (v => `$${v < 1 ? v.toFixed(2) : d3.format('.2s')(v)}`) : d3.format('.2s');
93
- const yAxis = svg.append('g').call(d3.axisLeft(y).ticks(5).tickFormat(yAxisFmt));
94
- yAxis.selectAll('text').style('fill', '#64748b').style('font-size', '10px');
95
- yAxis.selectAll('line, path').style('stroke', '#334155');
96
-
97
- if (showDollars) {
98
- // Single bar per bucket showing cost
99
- svg.selectAll('.bar-cost')
100
- .data(buckets)
101
- .enter().append('rect')
102
- .attr('x', d => x(d.time))
103
- .attr('y', d => y(costOf(d)))
104
- .attr('width', x.bandwidth())
105
- .attr('height', d => height - y(costOf(d)))
106
- .attr('fill', '#fbbf24')
107
- .attr('opacity', 0.7);
108
- } else {
109
- // Stack order (bottom to top): cache_read, cache_creation, input, output
110
- // Cache read (bottom)
111
- svg.selectAll('.bar-cache-read')
112
- .data(buckets)
113
- .enter().append('rect')
114
- .attr('x', d => x(d.time))
115
- .attr('y', d => y(d.cache_read_tokens || 0))
116
- .attr('width', x.bandwidth())
117
- .attr('height', d => height - y(d.cache_read_tokens || 0))
118
- .attr('fill', '#4ade80')
119
- .attr('opacity', 0.6);
120
-
121
- // Cache creation (on top of cache read)
122
- const cacheBase = d => (d.cache_read_tokens || 0);
123
- svg.selectAll('.bar-cache-creation')
124
- .data(buckets)
125
- .enter().append('rect')
126
- .attr('x', d => x(d.time))
127
- .attr('y', d => y(cacheBase(d) + (d.cache_creation_tokens || 0)))
128
- .attr('width', x.bandwidth())
129
- .attr('height', d => y(cacheBase(d)) - y(cacheBase(d) + (d.cache_creation_tokens || 0)))
130
- .attr('fill', '#f59e0b')
131
- .attr('opacity', 0.6);
132
-
133
- // Input (on top of cache)
134
- const inputBase = d => cacheBase(d) + (d.cache_creation_tokens || 0);
135
- svg.selectAll('.bar-input')
136
- .data(buckets)
137
- .enter().append('rect')
138
- .attr('x', d => x(d.time))
139
- .attr('y', d => y(inputBase(d) + d.input_tokens))
140
- .attr('width', x.bandwidth())
141
- .attr('height', d => y(inputBase(d)) - y(inputBase(d) + d.input_tokens))
142
- .attr('fill', '#3b82f6')
143
- .attr('opacity', 0.7);
144
-
145
- // Output (top)
146
- const outputBase = d => inputBase(d) + d.input_tokens;
147
- svg.selectAll('.bar-output')
148
- .data(buckets)
149
- .enter().append('rect')
150
- .attr('x', d => x(d.time))
151
- .attr('y', d => y(outputBase(d) + d.output_tokens))
152
- .attr('width', x.bandwidth())
153
- .attr('height', d => y(outputBase(d)) - y(outputBase(d) + d.output_tokens))
154
- .attr('fill', '#f97316')
155
- .attr('opacity', 0.7);
156
- }
157
-
158
- // Tooltip — remove any stale ones first
159
- d3.selectAll('.d3-tooltip-token-trend').remove();
160
- const tooltip = d3.select('body').append('div').attr('class', 'd3-tooltip d3-tooltip-token-trend').style('display', 'none');
161
-
162
- svg.selectAll('rect')
163
- .on('mouseover', (event, d) => {
164
- const total = d.input_tokens + d.output_tokens + (d.cache_read_tokens || 0) + (d.cache_creation_tokens || 0);
165
- const cost = d.estimated_cost_usd || 0;
166
- tooltip.style('display', 'block')
167
- .html(`<strong>${d.time}</strong><br>Total: ${d3.format(',')(total)} tokens &nbsp;<span style="color:#f59e0b;font-weight:600">$${cost.toFixed(2)}</span><br><span style="color:#4ade80">Cache Read: ${d3.format(',')(d.cache_read_tokens || 0)}</span><br><span style="color:#f59e0b">Cache Write: ${d3.format(',')(d.cache_creation_tokens || 0)}</span><br><span style="color:#60a5fa">Input: ${d3.format(',')(d.input_tokens)}</span><br><span style="color:#f97316">Output: ${d3.format(',')(d.output_tokens)}</span>`);
168
- })
169
- .on('mousemove', (event) => {
170
- tooltip.style('left', (event.pageX + 10) + 'px').style('top', (event.pageY - 10) + 'px');
171
- })
172
- .on('mouseout', () => tooltip.style('display', 'none'));
173
-
174
- // Aggregated totals for the selected period
175
- const fmt = d3.format(',');
176
- const fmtShort = (n) => {
177
- if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(1) + 'B';
178
- if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
179
- if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
180
- return n.toString();
181
- };
182
- // Use server-computed totals (from raw records) for accuracy;
183
- // fall back to summing buckets if data.total is unavailable
184
- const t = data.total || {};
185
- const totals = {
186
- cacheRead: t.cache_read_tokens ?? buckets.reduce((s, d) => s + (d.cache_read_tokens || 0), 0),
187
- cacheWrite: t.cache_creation_tokens ?? buckets.reduce((s, d) => s + (d.cache_creation_tokens || 0), 0),
188
- input: t.input_tokens ?? buckets.reduce((s, d) => s + d.input_tokens, 0),
189
- output: t.output_tokens ?? buckets.reduce((s, d) => s + d.output_tokens, 0),
190
- cost: t.estimated_api_cost_usd ?? buckets.reduce((s, d) => s + (d.estimated_cost_usd || 0), 0),
191
- };
192
- totals.all = totals.cacheRead + totals.cacheWrite + totals.input + totals.output;
193
-
194
- const summary = el.append('div')
195
- .style('margin-top', '10px')
196
- .style('padding', '10px 14px')
197
- .style('background', '#1e293b')
198
- .style('border-radius', '6px')
199
- .style('font-size', '12px');
200
-
201
- // Top row: total tokens + cost inline
202
- const topRow = summary.append('div')
203
- .style('margin-bottom', '8px');
204
- topRow.append('span')
205
- .style('color', '#e2e8f0')
206
- .style('font-size', '13px')
207
- .html(`Period Total: <strong title="${fmt(totals.all)} tokens">${fmtShort(totals.all)}</strong> tokens`)
208
- .append('span')
209
- .style('color', '#fbbf24')
210
- .style('font-weight', '600')
211
- .style('margin-left', '12px')
212
- .html(`$${totals.cost.toFixed(2)}`);
213
-
214
- // Segment breakdown with legend dots
215
- const segments = [
216
- { label: 'Cache Read', value: totals.cacheRead, color: '#4ade80' },
217
- { label: 'Cache Write', value: totals.cacheWrite, color: '#f59e0b' },
218
- { label: 'Input', value: totals.input, color: '#60a5fa' },
219
- { label: 'Output', value: totals.output, color: '#f97316' },
220
- ];
221
- const segRow = summary.append('div')
222
- .style('display', 'flex')
223
- .style('flex-wrap', 'wrap')
224
- .style('gap', '6px 20px')
225
- .style('margin-bottom', '8px');
226
- for (const s of segments) {
227
- segRow.append('span')
228
- .style('color', s.color)
229
- .style('font-size', '11px')
230
- .html(`● ${s.label}: <strong title="${fmt(s.value)} tokens">${fmtShort(s.value)}</strong>`);
231
- }
232
-
233
- // Avg / Min / Max stats per bucket
234
- const bucketVals = buckets.map(valueOf);
235
- const nonZero = bucketVals.filter(v => v > 0);
236
- const avg = nonZero.length > 0 ? nonZero.reduce((a, b) => a + b, 0) / nonZero.length : 0;
237
- const min = nonZero.length > 0 ? Math.min(...nonZero) : 0;
238
- const max = nonZero.length > 0 ? Math.max(...nonZero) : 0;
239
-
240
- const fmtStat = showDollars
241
- ? (v => `$${v.toFixed(2)}`)
242
- : (v => fmtShort(Math.round(v)));
243
- const fmtStatTitle = showDollars
244
- ? (v => `$${v.toFixed(4)}`)
245
- : (v => `${fmt(Math.round(v))} tokens`);
246
-
247
- const granLabel = { hourly: 'hour', daily: 'day', weekly: 'week', monthly: 'month' }[data.granularity] || 'bucket';
248
- const statsRow = summary.append('div')
249
- .style('display', 'flex')
250
- .style('flex-wrap', 'wrap')
251
- .style('gap', '6px 20px')
252
- .style('font-size', '11px')
253
- .style('color', '#94a3b8');
254
-
255
- statsRow.append('span').html(`Avg/${granLabel}: <strong title="${fmtStatTitle(avg)}" style="color:#e2e8f0">${fmtStat(avg)}</strong>`);
256
- statsRow.append('span').html(`Min: <strong title="${fmtStatTitle(min)}" style="color:#e2e8f0">${fmtStat(min)}</strong>`);
257
- statsRow.append('span').html(`Max: <strong title="${fmtStatTitle(max)}" style="color:#e2e8f0">${fmtStat(max)}</strong>`);
258
- statsRow.append('span').html(`Active ${granLabel}s: <strong style="color:#e2e8f0">${nonZero.length}</strong> / ${buckets.length}`);
259
-
260
- if (data.granularity === 'hourly') {
261
- const days = new Set(buckets.map(b => b.time.slice(0, 10)));
262
- const avgHours = (nonZero.length / days.size).toFixed(1);
263
- statsRow.append('span').html(`Avg hours/day: <strong style="color:#e2e8f0">${avgHours}</strong>`);
264
- } else if (data.granularity === 'daily') {
265
- const weeks = Math.max(1, buckets.length / 7);
266
- const avgDays = (nonZero.length / weeks).toFixed(1);
267
- statsRow.append('span').html(`Avg days/week: <strong style="color:#e2e8f0">${avgDays}</strong>`);
268
- }
269
-
270
- // Most active hours heatmap — aggregate by hour-of-day across the entire range
271
- const hourAgg = new Array(24).fill(0);
272
- const hourCount = new Array(24).fill(0);
273
- for (const b of buckets) {
274
- // Extract hour from hourly buckets (e.g. "2026-03-15T08:00") or from daily/other granularities skip
275
- const hm = b.time.match(/T(\d{2}):00$/);
276
- if (hm) {
277
- const hr = parseInt(hm[1], 10);
278
- hourAgg[hr] += valueOf(b);
279
- if (valueOf(b) > 0) hourCount[hr]++;
280
- }
281
- }
282
- const hasHourlyData = hourAgg.some(v => v > 0);
283
- if (hasHourlyData) {
284
- const peakHour = hourAgg.indexOf(Math.max(...hourAgg));
285
- const maxHourVal = Math.max(...hourAgg);
286
-
287
- // Find contiguous active ranges
288
- const activeHours = hourAgg.map((v, i) => ({ hour: i, val: v })).filter(h => h.val > 0);
289
- const ranges = [];
290
- if (activeHours.length > 0) {
291
- let start = activeHours[0].hour;
292
- let prev = start;
293
- for (let i = 1; i < activeHours.length; i++) {
294
- if (activeHours[i].hour === prev + 1) {
295
- prev = activeHours[i].hour;
296
- } else {
297
- ranges.push([start, prev]);
298
- start = activeHours[i].hour;
299
- prev = start;
300
- }
301
- }
302
- ranges.push([start, prev]);
303
- }
304
-
305
- const fmtHr = h => {
306
- if (h === 0) return '12AM';
307
- if (h < 12) return `${h}AM`;
308
- if (h === 12) return '12PM';
309
- return `${h - 12}PM`;
310
- };
311
-
312
- const activeRangeStr = ranges.map(([s, e]) => s === e ? fmtHr(s) : `${fmtHr(s)}-${fmtHr((e + 1) % 24)}`).join(', ');
313
-
314
- const hoursRow = summary.append('div')
315
- .style('margin-top', '8px')
316
- .style('font-size', '11px')
317
- .style('color', '#94a3b8');
318
-
319
- hoursRow.append('div')
320
- .style('margin-bottom', '4px')
321
- .html(`Peak hour: <strong style="color:#e2e8f0">${fmtHr(peakHour)}</strong> &nbsp; Active: <strong style="color:#e2e8f0">${activeRangeStr}</strong>`);
322
-
323
- // Mini hour heatmap bar
324
- const heatmap = hoursRow.append('div')
325
- .style('display', 'flex')
326
- .style('gap', '1px')
327
- .style('align-items', 'end')
328
- .style('height', '20px');
329
-
330
- for (let h = 0; h < 24; h++) {
331
- const pct = maxHourVal > 0 ? hourAgg[h] / maxHourVal : 0;
332
- const color = pct === 0 ? '#1e293b'
333
- : pct < 0.33 ? '#334155'
334
- : pct < 0.66 ? '#3b82f6'
335
- : '#60a5fa';
336
- heatmap.append('div')
337
- .attr('title', `${fmtHr(h)}: ${showDollars ? '$' + hourAgg[h].toFixed(2) : fmtShort(Math.round(hourAgg[h]))}`)
338
- .style('flex', '1')
339
- .style('height', `${Math.max(2, pct * 100)}%`)
340
- .style('background', color)
341
- .style('border-radius', '1px');
342
- }
343
-
344
- // Hour labels under heatmap
345
- const labels = hoursRow.append('div')
346
- .style('display', 'flex')
347
- .style('gap', '1px');
348
- for (let h = 0; h < 24; h++) {
349
- labels.append('div')
350
- .style('flex', '1')
351
- .style('text-align', 'center')
352
- .style('font-size', '8px')
353
- .style('color', '#64748b')
354
- .text(h % 3 === 0 ? fmtHr(h) : '');
355
- }
356
- }
357
- }
1
+ // d3 is loaded as a global via <script> tag in index.html
2
+
3
+ export function renderTokenTrend(container, data, opts = {}) {
4
+ const showDollars = opts.yAxis === 'dollars';
5
+ const el = d3.select(container);
6
+ el.selectAll('*').remove();
7
+
8
+ if (!data.buckets || data.buckets.length === 0) {
9
+ el.append('p').style('color', '#64748b').text('No data for selected range');
10
+ return;
11
+ }
12
+
13
+ const margin = { top: 20, right: 30, bottom: 60, left: 60 };
14
+ const width = container.clientWidth - margin.left - margin.right;
15
+ const height = 250 - margin.top - margin.bottom;
16
+
17
+ const svg = el.append('svg')
18
+ .attr('width', width + margin.left + margin.right)
19
+ .attr('height', height + margin.top + margin.bottom)
20
+ .append('g')
21
+ .attr('transform', `translate(${margin.left},${margin.top})`);
22
+
23
+ const emptyBucket = (time) => ({ time, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0, estimated_cost_usd: 0 });
24
+
25
+ // Fill in missing time slots so blank periods are visible
26
+ const bucketMap = new Map(data.buckets.map(b => [b.time, b]));
27
+ let allKeys;
28
+ if (data.granularity === 'hourly') {
29
+ allKeys = [];
30
+ const first = data.buckets[0].time; // e.g. "2026-03-15T08:00"
31
+ const last = data.buckets[data.buckets.length - 1].time;
32
+ const pad = n => String(n).padStart(2, '0');
33
+ const cur = new Date(first.replace('T', ' ').replace(/:00$/, ':00:00'));
34
+ const end = new Date(last.replace('T', ' ').replace(/:00$/, ':00:00'));
35
+ while (cur <= end) {
36
+ const key = `${cur.getFullYear()}-${pad(cur.getMonth() + 1)}-${pad(cur.getDate())}T${pad(cur.getHours())}:00`;
37
+ allKeys.push(key);
38
+ cur.setHours(cur.getHours() + 1);
39
+ }
40
+ } else if (data.granularity === 'daily') {
41
+ allKeys = [];
42
+ const pad = n => String(n).padStart(2, '0');
43
+ const cur = new Date(data.buckets[0].time + 'T00:00:00');
44
+ const end = new Date(data.buckets[data.buckets.length - 1].time + 'T00:00:00');
45
+ while (cur <= end) {
46
+ allKeys.push(`${cur.getFullYear()}-${pad(cur.getMonth() + 1)}-${pad(cur.getDate())}`);
47
+ cur.setDate(cur.getDate() + 1);
48
+ }
49
+ } else {
50
+ allKeys = data.buckets.map(b => b.time);
51
+ }
52
+ const buckets = allKeys.map(k => bucketMap.get(k) || emptyBucket(k));
53
+
54
+ const x = d3.scaleBand()
55
+ .domain(buckets.map(d => d.time))
56
+ .range([0, width])
57
+ .padding(0.1);
58
+
59
+ // Helper to get total height for each bucket
60
+ const totalOf = d => d.input_tokens + d.output_tokens + (d.cache_read_tokens || 0) + (d.cache_creation_tokens || 0);
61
+ const costOf = d => d.estimated_cost_usd || 0;
62
+ const valueOf = showDollars ? costOf : totalOf;
63
+
64
+ const maxVal = d3.max(buckets, valueOf) || 1;
65
+ const y = d3.scaleLinear().domain([0, maxVal * 1.1]).range([height, 0]);
66
+
67
+ const maxTicks = data.granularity === 'hourly' ? 12 : 10;
68
+ const tickVals = x.domain().filter((_, i) => i % Math.ceil(buckets.length / maxTicks) === 0);
69
+ const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
70
+ const formatTick = (t) => {
71
+ // Hourly: "2026-03-15T08:00" → "Mar 15 8AM"
72
+ const h = t.match(/^\d{4}-(\d{2})-(\d{2})T(\d{2}):00$/);
73
+ if (h) {
74
+ const hr = parseInt(h[3], 10);
75
+ const ampm = hr === 0 ? '12AM' : hr < 12 ? `${hr}AM` : hr === 12 ? '12PM' : `${hr - 12}PM`;
76
+ return `${months[parseInt(h[1], 10) - 1]} ${parseInt(h[2], 10)} ${ampm}`;
77
+ }
78
+ // Daily: "2026-03-08" → "Mar 8"
79
+ const m = t.match(/^\d{4}-(\d{2})-(\d{2})$/);
80
+ if (m) {
81
+ return `${months[parseInt(m[1], 10) - 1]} ${parseInt(m[2], 10)}`;
82
+ }
83
+ return t;
84
+ };
85
+ const xAxis = svg.append('g')
86
+ .attr('transform', `translate(0,${height})`)
87
+ .call(d3.axisBottom(x).tickValues(tickVals).tickFormat(formatTick));
88
+ xAxis.selectAll('text').style('fill', '#64748b').style('font-size', '10px')
89
+ .attr('transform', 'rotate(-45)').attr('text-anchor', 'end');
90
+ xAxis.selectAll('line, path').style('stroke', '#334155');
91
+
92
+ const yAxisFmt = showDollars ? (v => `$${v < 1 ? v.toFixed(2) : d3.format('.2s')(v)}`) : d3.format('.2s');
93
+ const yAxis = svg.append('g').call(d3.axisLeft(y).ticks(5).tickFormat(yAxisFmt));
94
+ yAxis.selectAll('text').style('fill', '#64748b').style('font-size', '10px');
95
+ yAxis.selectAll('line, path').style('stroke', '#334155');
96
+
97
+ if (showDollars) {
98
+ // Single bar per bucket showing cost
99
+ svg.selectAll('.bar-cost')
100
+ .data(buckets)
101
+ .enter().append('rect')
102
+ .attr('x', d => x(d.time))
103
+ .attr('y', d => y(costOf(d)))
104
+ .attr('width', x.bandwidth())
105
+ .attr('height', d => height - y(costOf(d)))
106
+ .attr('fill', '#fbbf24')
107
+ .attr('opacity', 0.7);
108
+ } else {
109
+ // Stack order (bottom to top): cache_read, cache_creation, input, output
110
+ // Cache read (bottom)
111
+ svg.selectAll('.bar-cache-read')
112
+ .data(buckets)
113
+ .enter().append('rect')
114
+ .attr('x', d => x(d.time))
115
+ .attr('y', d => y(d.cache_read_tokens || 0))
116
+ .attr('width', x.bandwidth())
117
+ .attr('height', d => height - y(d.cache_read_tokens || 0))
118
+ .attr('fill', '#4ade80')
119
+ .attr('opacity', 0.6);
120
+
121
+ // Cache creation (on top of cache read)
122
+ const cacheBase = d => (d.cache_read_tokens || 0);
123
+ svg.selectAll('.bar-cache-creation')
124
+ .data(buckets)
125
+ .enter().append('rect')
126
+ .attr('x', d => x(d.time))
127
+ .attr('y', d => y(cacheBase(d) + (d.cache_creation_tokens || 0)))
128
+ .attr('width', x.bandwidth())
129
+ .attr('height', d => y(cacheBase(d)) - y(cacheBase(d) + (d.cache_creation_tokens || 0)))
130
+ .attr('fill', '#f59e0b')
131
+ .attr('opacity', 0.6);
132
+
133
+ // Input (on top of cache)
134
+ const inputBase = d => cacheBase(d) + (d.cache_creation_tokens || 0);
135
+ svg.selectAll('.bar-input')
136
+ .data(buckets)
137
+ .enter().append('rect')
138
+ .attr('x', d => x(d.time))
139
+ .attr('y', d => y(inputBase(d) + d.input_tokens))
140
+ .attr('width', x.bandwidth())
141
+ .attr('height', d => y(inputBase(d)) - y(inputBase(d) + d.input_tokens))
142
+ .attr('fill', '#3b82f6')
143
+ .attr('opacity', 0.7);
144
+
145
+ // Output (top)
146
+ const outputBase = d => inputBase(d) + d.input_tokens;
147
+ svg.selectAll('.bar-output')
148
+ .data(buckets)
149
+ .enter().append('rect')
150
+ .attr('x', d => x(d.time))
151
+ .attr('y', d => y(outputBase(d) + d.output_tokens))
152
+ .attr('width', x.bandwidth())
153
+ .attr('height', d => y(outputBase(d)) - y(outputBase(d) + d.output_tokens))
154
+ .attr('fill', '#f97316')
155
+ .attr('opacity', 0.7);
156
+ }
157
+
158
+ // Tooltip — remove any stale ones first
159
+ d3.selectAll('.d3-tooltip-token-trend').remove();
160
+ const tooltip = d3.select('body').append('div').attr('class', 'd3-tooltip d3-tooltip-token-trend').style('display', 'none');
161
+
162
+ svg.selectAll('rect')
163
+ .on('mouseover', (event, d) => {
164
+ const total = d.input_tokens + d.output_tokens + (d.cache_read_tokens || 0) + (d.cache_creation_tokens || 0);
165
+ const cost = d.estimated_cost_usd || 0;
166
+ tooltip.style('display', 'block')
167
+ .html(`<strong>${d.time}</strong><br>Total: ${d3.format(',')(total)} tokens &nbsp;<span style="color:#f59e0b;font-weight:600">$${cost.toFixed(2)}</span><br><span style="color:#4ade80">Cache Read: ${d3.format(',')(d.cache_read_tokens || 0)}</span><br><span style="color:#f59e0b">Cache Write: ${d3.format(',')(d.cache_creation_tokens || 0)}</span><br><span style="color:#60a5fa">Input: ${d3.format(',')(d.input_tokens)}</span><br><span style="color:#f97316">Output: ${d3.format(',')(d.output_tokens)}</span>`);
168
+ })
169
+ .on('mousemove', (event) => {
170
+ tooltip.style('left', (event.pageX + 10) + 'px').style('top', (event.pageY - 10) + 'px');
171
+ })
172
+ .on('mouseout', () => tooltip.style('display', 'none'));
173
+
174
+ // Aggregated totals for the selected period
175
+ const fmt = d3.format(',');
176
+ const fmtShort = (n) => {
177
+ if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(1) + 'B';
178
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
179
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
180
+ return n.toString();
181
+ };
182
+ // Use server-computed totals (from raw records) for accuracy;
183
+ // fall back to summing buckets if data.total is unavailable
184
+ const t = data.total || {};
185
+ const totals = {
186
+ cacheRead: t.cache_read_tokens ?? buckets.reduce((s, d) => s + (d.cache_read_tokens || 0), 0),
187
+ cacheWrite: t.cache_creation_tokens ?? buckets.reduce((s, d) => s + (d.cache_creation_tokens || 0), 0),
188
+ input: t.input_tokens ?? buckets.reduce((s, d) => s + d.input_tokens, 0),
189
+ output: t.output_tokens ?? buckets.reduce((s, d) => s + d.output_tokens, 0),
190
+ cost: t.estimated_api_cost_usd ?? buckets.reduce((s, d) => s + (d.estimated_cost_usd || 0), 0),
191
+ };
192
+ totals.all = totals.cacheRead + totals.cacheWrite + totals.input + totals.output;
193
+
194
+ const summary = el.append('div')
195
+ .style('margin-top', '10px')
196
+ .style('padding', '10px 14px')
197
+ .style('background', '#1e293b')
198
+ .style('border-radius', '6px')
199
+ .style('font-size', '12px');
200
+
201
+ // Top row: total tokens + cost inline
202
+ const topRow = summary.append('div')
203
+ .style('margin-bottom', '8px');
204
+ topRow.append('span')
205
+ .style('color', '#e2e8f0')
206
+ .style('font-size', '13px')
207
+ .html(`Period Total: <strong title="${fmt(totals.all)} tokens">${fmtShort(totals.all)}</strong> tokens`)
208
+ .append('span')
209
+ .style('color', '#fbbf24')
210
+ .style('font-weight', '600')
211
+ .style('margin-left', '12px')
212
+ .html(`$${totals.cost.toFixed(2)}`);
213
+
214
+ // Segment breakdown with legend dots
215
+ const segments = [
216
+ { label: 'Cache Read', value: totals.cacheRead, color: '#4ade80' },
217
+ { label: 'Cache Write', value: totals.cacheWrite, color: '#f59e0b' },
218
+ { label: 'Input', value: totals.input, color: '#60a5fa' },
219
+ { label: 'Output', value: totals.output, color: '#f97316' },
220
+ ];
221
+ const segRow = summary.append('div')
222
+ .style('display', 'flex')
223
+ .style('flex-wrap', 'wrap')
224
+ .style('gap', '6px 20px')
225
+ .style('margin-bottom', '8px');
226
+ for (const s of segments) {
227
+ segRow.append('span')
228
+ .style('color', s.color)
229
+ .style('font-size', '11px')
230
+ .html(`● ${s.label}: <strong title="${fmt(s.value)} tokens">${fmtShort(s.value)}</strong>`);
231
+ }
232
+
233
+ // Avg / Min / Max stats per bucket
234
+ const bucketVals = buckets.map(valueOf);
235
+ const nonZero = bucketVals.filter(v => v > 0);
236
+ const avg = nonZero.length > 0 ? nonZero.reduce((a, b) => a + b, 0) / nonZero.length : 0;
237
+ const min = nonZero.length > 0 ? Math.min(...nonZero) : 0;
238
+ const max = nonZero.length > 0 ? Math.max(...nonZero) : 0;
239
+
240
+ const fmtStat = showDollars
241
+ ? (v => `$${v.toFixed(2)}`)
242
+ : (v => fmtShort(Math.round(v)));
243
+ const fmtStatTitle = showDollars
244
+ ? (v => `$${v.toFixed(4)}`)
245
+ : (v => `${fmt(Math.round(v))} tokens`);
246
+
247
+ const granLabel = { hourly: 'hour', daily: 'day', weekly: 'week', monthly: 'month' }[data.granularity] || 'bucket';
248
+ const statsRow = summary.append('div')
249
+ .style('display', 'flex')
250
+ .style('flex-wrap', 'wrap')
251
+ .style('gap', '6px 20px')
252
+ .style('font-size', '11px')
253
+ .style('color', '#94a3b8');
254
+
255
+ statsRow.append('span').html(`Avg/${granLabel}: <strong title="${fmtStatTitle(avg)}" style="color:#e2e8f0">${fmtStat(avg)}</strong>`);
256
+ statsRow.append('span').html(`Min: <strong title="${fmtStatTitle(min)}" style="color:#e2e8f0">${fmtStat(min)}</strong>`);
257
+ statsRow.append('span').html(`Max: <strong title="${fmtStatTitle(max)}" style="color:#e2e8f0">${fmtStat(max)}</strong>`);
258
+ statsRow.append('span').html(`Active ${granLabel}s: <strong style="color:#e2e8f0">${nonZero.length}</strong> / ${buckets.length}`);
259
+
260
+ if (data.granularity === 'hourly') {
261
+ const days = new Set(buckets.map(b => b.time.slice(0, 10)));
262
+ const avgHours = (nonZero.length / days.size).toFixed(1);
263
+ statsRow.append('span').html(`Avg hours/day: <strong style="color:#e2e8f0">${avgHours}</strong>`);
264
+ } else if (data.granularity === 'daily') {
265
+ const weeks = Math.max(1, buckets.length / 7);
266
+ const avgDays = (nonZero.length / weeks).toFixed(1);
267
+ statsRow.append('span').html(`Avg days/week: <strong style="color:#e2e8f0">${avgDays}</strong>`);
268
+ }
269
+
270
+ // Most active hours heatmap — aggregate by hour-of-day across the entire range
271
+ const hourAgg = new Array(24).fill(0);
272
+ const hourCount = new Array(24).fill(0);
273
+ for (const b of buckets) {
274
+ // Extract hour from hourly buckets (e.g. "2026-03-15T08:00") or from daily/other granularities skip
275
+ const hm = b.time.match(/T(\d{2}):00$/);
276
+ if (hm) {
277
+ const hr = parseInt(hm[1], 10);
278
+ hourAgg[hr] += valueOf(b);
279
+ if (valueOf(b) > 0) hourCount[hr]++;
280
+ }
281
+ }
282
+ const hasHourlyData = hourAgg.some(v => v > 0);
283
+ if (hasHourlyData) {
284
+ const peakHour = hourAgg.indexOf(Math.max(...hourAgg));
285
+ const maxHourVal = Math.max(...hourAgg);
286
+
287
+ // Find contiguous active ranges
288
+ const activeHours = hourAgg.map((v, i) => ({ hour: i, val: v })).filter(h => h.val > 0);
289
+ const ranges = [];
290
+ if (activeHours.length > 0) {
291
+ let start = activeHours[0].hour;
292
+ let prev = start;
293
+ for (let i = 1; i < activeHours.length; i++) {
294
+ if (activeHours[i].hour === prev + 1) {
295
+ prev = activeHours[i].hour;
296
+ } else {
297
+ ranges.push([start, prev]);
298
+ start = activeHours[i].hour;
299
+ prev = start;
300
+ }
301
+ }
302
+ ranges.push([start, prev]);
303
+ }
304
+
305
+ const fmtHr = h => {
306
+ if (h === 0) return '12AM';
307
+ if (h < 12) return `${h}AM`;
308
+ if (h === 12) return '12PM';
309
+ return `${h - 12}PM`;
310
+ };
311
+
312
+ const activeRangeStr = ranges.map(([s, e]) => s === e ? fmtHr(s) : `${fmtHr(s)}-${fmtHr((e + 1) % 24)}`).join(', ');
313
+
314
+ const hoursRow = summary.append('div')
315
+ .style('margin-top', '8px')
316
+ .style('font-size', '11px')
317
+ .style('color', '#94a3b8');
318
+
319
+ hoursRow.append('div')
320
+ .style('margin-bottom', '4px')
321
+ .html(`Peak hour: <strong style="color:#e2e8f0">${fmtHr(peakHour)}</strong> &nbsp; Active: <strong style="color:#e2e8f0">${activeRangeStr}</strong>`);
322
+
323
+ // Mini hour heatmap bar
324
+ const heatmap = hoursRow.append('div')
325
+ .style('display', 'flex')
326
+ .style('gap', '1px')
327
+ .style('align-items', 'end')
328
+ .style('height', '20px');
329
+
330
+ for (let h = 0; h < 24; h++) {
331
+ const pct = maxHourVal > 0 ? hourAgg[h] / maxHourVal : 0;
332
+ const color = pct === 0 ? '#1e293b'
333
+ : pct < 0.33 ? '#334155'
334
+ : pct < 0.66 ? '#3b82f6'
335
+ : '#60a5fa';
336
+ heatmap.append('div')
337
+ .attr('title', `${fmtHr(h)}: ${showDollars ? '$' + hourAgg[h].toFixed(2) : fmtShort(Math.round(hourAgg[h]))}`)
338
+ .style('flex', '1')
339
+ .style('height', `${Math.max(2, pct * 100)}%`)
340
+ .style('background', color)
341
+ .style('border-radius', '1px');
342
+ }
343
+
344
+ // Hour labels under heatmap
345
+ const labels = hoursRow.append('div')
346
+ .style('display', 'flex')
347
+ .style('gap', '1px');
348
+ for (let h = 0; h < 24; h++) {
349
+ labels.append('div')
350
+ .style('flex', '1')
351
+ .style('text-align', 'center')
352
+ .style('font-size', '8px')
353
+ .style('color', '#64748b')
354
+ .text(h % 3 === 0 ? fmtHr(h) : '');
355
+ }
356
+ }
357
+ }