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,39 +1,39 @@
1
- export function renderCostComparison(container, data) {
2
- const el = d3.select(container);
3
- el.selectAll('*').remove();
4
-
5
- const margin = { top: 10, right: 20, bottom: 40, left: 50 };
6
- const width = container.clientWidth - margin.left - margin.right;
7
- const height = 180 - margin.top - margin.bottom;
8
-
9
- const svg = el.append('svg')
10
- .attr('width', width + margin.left + margin.right)
11
- .attr('height', height + margin.top + margin.bottom)
12
- .append('g')
13
- .attr('transform', `translate(${margin.left},${margin.top})`);
14
-
15
- const bars = [
16
- { label: 'Subscription', value: data.subscription_cost_usd, color: '#3b82f6' },
17
- { label: 'API Cost', value: data.api_equivalent_cost_usd, color: '#f59e0b' },
18
- ];
19
-
20
- const x = d3.scaleBand().domain(bars.map(d => d.label)).range([0, width]).padding(0.4);
21
- const y = d3.scaleLinear().domain([0, d3.max(bars, d => d.value) * 1.2]).range([height, 0]);
22
-
23
- svg.append('g').attr('transform', `translate(0,${height})`)
24
- .call(d3.axisBottom(x))
25
- .selectAll('text').style('fill', '#94a3b8').style('font-size', '11px');
26
- svg.append('g').call(d3.axisLeft(y).ticks(4).tickFormat(d => `$${d}`))
27
- .selectAll('text').style('fill', '#64748b').style('font-size', '10px');
28
-
29
- svg.selectAll('.bar').data(bars).enter().append('rect')
30
- .attr('x', d => x(d.label)).attr('y', d => y(d.value))
31
- .attr('width', x.bandwidth()).attr('height', d => height - y(d.value))
32
- .attr('fill', d => d.color).attr('rx', 4);
33
-
34
- svg.selectAll('.label').data(bars).enter().append('text')
35
- .attr('x', d => x(d.label) + x.bandwidth() / 2).attr('y', d => y(d.value) - 5)
36
- .attr('text-anchor', 'middle')
37
- .style('fill', '#f8fafc').style('font-size', '12px').style('font-weight', '600')
38
- .text(d => `$${d.value.toFixed(2)}`);
39
- }
1
+ export function renderCostComparison(container, data) {
2
+ const el = d3.select(container);
3
+ el.selectAll('*').remove();
4
+
5
+ const margin = { top: 10, right: 20, bottom: 40, left: 50 };
6
+ const width = container.clientWidth - margin.left - margin.right;
7
+ const height = 180 - margin.top - margin.bottom;
8
+
9
+ const svg = el.append('svg')
10
+ .attr('width', width + margin.left + margin.right)
11
+ .attr('height', height + margin.top + margin.bottom)
12
+ .append('g')
13
+ .attr('transform', `translate(${margin.left},${margin.top})`);
14
+
15
+ const bars = [
16
+ { label: 'Subscription', value: data.subscription_cost_usd, color: '#3b82f6' },
17
+ { label: 'API Cost', value: data.api_equivalent_cost_usd, color: '#f59e0b' },
18
+ ];
19
+
20
+ const x = d3.scaleBand().domain(bars.map(d => d.label)).range([0, width]).padding(0.4);
21
+ const y = d3.scaleLinear().domain([0, d3.max(bars, d => d.value) * 1.2]).range([height, 0]);
22
+
23
+ svg.append('g').attr('transform', `translate(0,${height})`)
24
+ .call(d3.axisBottom(x))
25
+ .selectAll('text').style('fill', '#94a3b8').style('font-size', '11px');
26
+ svg.append('g').call(d3.axisLeft(y).ticks(4).tickFormat(d => `$${d}`))
27
+ .selectAll('text').style('fill', '#64748b').style('font-size', '10px');
28
+
29
+ svg.selectAll('.bar').data(bars).enter().append('rect')
30
+ .attr('x', d => x(d.label)).attr('y', d => y(d.value))
31
+ .attr('width', x.bandwidth()).attr('height', d => height - y(d.value))
32
+ .attr('fill', d => d.color).attr('rx', 4);
33
+
34
+ svg.selectAll('.label').data(bars).enter().append('text')
35
+ .attr('x', d => x(d.label) + x.bandwidth() / 2).attr('y', d => y(d.value) - 5)
36
+ .attr('text-anchor', 'middle')
37
+ .style('fill', '#f8fafc').style('font-size', '12px').style('font-weight', '600')
38
+ .text(d => `$${d.value.toFixed(2)}`);
39
+ }
@@ -1,56 +1,56 @@
1
- const MODEL_COLORS = {
2
- 'claude-sonnet-4-6': '#3b82f6',
3
- 'claude-opus-4-6': '#8b5cf6',
4
- 'claude-haiku-4-5': '#f59e0b',
5
- };
6
-
7
- const MODEL_DISPLAY = {
8
- 'claude-opus-4-6': 'opus 4.6',
9
- 'claude-sonnet-4-6': 'sonnet 4.6',
10
- 'claude-haiku-4-5': 'haiku 4.5',
11
- 'claude-haiku-4-5-20251001': 'haiku 4.5',
12
- };
13
-
14
- export function renderModelDistribution(container, data) {
15
- const el = d3.select(container);
16
- el.selectAll('*').remove();
17
-
18
- if (!data.models || data.models.length === 0) {
19
- el.append('p').style('color', '#64748b').text('No data');
20
- return;
21
- }
22
-
23
- const containerWidth = container.clientWidth;
24
- const size = Math.min(containerWidth * 0.45, 200);
25
- const radius = size / 2;
26
- const innerRadius = radius * 0.55;
27
-
28
- const isNarrow = containerWidth < 280;
29
- const wrapper = el.append('div')
30
- .style('display', 'flex')
31
- .style('flex-direction', isNarrow ? 'column' : 'row')
32
- .style('align-items', 'center')
33
- .style('gap', isNarrow ? '8px' : '20px');
34
-
35
- const svg = wrapper.append('svg')
36
- .attr('width', size).attr('height', size)
37
- .style('flex-shrink', '0')
38
- .append('g').attr('transform', `translate(${size / 2},${size / 2})`);
39
-
40
- const total = d3.sum(data.models, d => d.total_tokens);
41
- const pie = d3.pie().value(d => d.total_tokens).sort(null);
42
- const arc = d3.arc().innerRadius(innerRadius).outerRadius(radius);
43
-
44
- svg.selectAll('path').data(pie(data.models)).enter().append('path')
45
- .attr('d', arc).attr('fill', d => MODEL_COLORS[d.data.id] || '#64748b')
46
- .attr('stroke', '#1e293b').attr('stroke-width', 2);
47
-
48
- const legend = wrapper.append('div');
49
- data.models.forEach(m => {
50
- const pct = ((m.total_tokens / total) * 100).toFixed(1);
51
- const color = MODEL_COLORS[m.id] || '#64748b';
52
- const shortName = MODEL_DISPLAY[m.id] || m.id.replace('claude-', '').replace(/-(\d+)-(\d+)/, ' $1.$2');
53
- legend.append('div').style('font-size', '11px').style('color', '#94a3b8').style('margin-bottom', '4px')
54
- .html(`<span style="color:${color}">●</span> ${shortName} — ${pct}%`);
55
- });
56
- }
1
+ const MODEL_COLORS = {
2
+ 'claude-sonnet-4-6': '#3b82f6',
3
+ 'claude-opus-4-6': '#8b5cf6',
4
+ 'claude-haiku-4-5': '#f59e0b',
5
+ };
6
+
7
+ const MODEL_DISPLAY = {
8
+ 'claude-opus-4-6': 'opus 4.6',
9
+ 'claude-sonnet-4-6': 'sonnet 4.6',
10
+ 'claude-haiku-4-5': 'haiku 4.5',
11
+ 'claude-haiku-4-5-20251001': 'haiku 4.5',
12
+ };
13
+
14
+ export function renderModelDistribution(container, data) {
15
+ const el = d3.select(container);
16
+ el.selectAll('*').remove();
17
+
18
+ if (!data.models || data.models.length === 0) {
19
+ el.append('p').style('color', '#64748b').text('No data');
20
+ return;
21
+ }
22
+
23
+ const containerWidth = container.clientWidth;
24
+ const size = Math.min(containerWidth * 0.45, 200);
25
+ const radius = size / 2;
26
+ const innerRadius = radius * 0.55;
27
+
28
+ const isNarrow = containerWidth < 280;
29
+ const wrapper = el.append('div')
30
+ .style('display', 'flex')
31
+ .style('flex-direction', isNarrow ? 'column' : 'row')
32
+ .style('align-items', 'center')
33
+ .style('gap', isNarrow ? '8px' : '20px');
34
+
35
+ const svg = wrapper.append('svg')
36
+ .attr('width', size).attr('height', size)
37
+ .style('flex-shrink', '0')
38
+ .append('g').attr('transform', `translate(${size / 2},${size / 2})`);
39
+
40
+ const total = d3.sum(data.models, d => d.total_tokens);
41
+ const pie = d3.pie().value(d => d.total_tokens).sort(null);
42
+ const arc = d3.arc().innerRadius(innerRadius).outerRadius(radius);
43
+
44
+ svg.selectAll('path').data(pie(data.models)).enter().append('path')
45
+ .attr('d', arc).attr('fill', d => MODEL_COLORS[d.data.id] || '#64748b')
46
+ .attr('stroke', '#1e293b').attr('stroke-width', 2);
47
+
48
+ const legend = wrapper.append('div');
49
+ data.models.forEach(m => {
50
+ const pct = ((m.total_tokens / total) * 100).toFixed(1);
51
+ const color = MODEL_COLORS[m.id] || '#64748b';
52
+ const shortName = MODEL_DISPLAY[m.id] || m.id.replace('claude-', '').replace(/-(\d+)-(\d+)/, ' $1.$2');
53
+ legend.append('div').style('font-size', '11px').style('color', '#94a3b8').style('margin-bottom', '4px')
54
+ .html(`<span style="color:${color}">●</span> ${shortName} — ${pct}%`);
55
+ });
56
+ }
@@ -1,103 +1,103 @@
1
- const COLORS = ['#3b82f6', '#8b5cf6', '#f59e0b', '#4ade80', '#ef4444', '#ec4899', '#06b6d4'];
2
-
3
- function fmt(n) {
4
- if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
5
- if (n >= 1_000) return (n / 1_000).toFixed(0) + 'K';
6
- return n.toString();
7
- }
8
-
9
- export function renderProjectDistribution(container, data) {
10
- const el = d3.select(container);
11
- el.selectAll('*').remove();
12
-
13
- if (!data.projects || data.projects.length === 0) {
14
- el.append('p').style('color', '#64748b').text('No data');
15
- return;
16
- }
17
-
18
- // Measure longest project name to set left margin dynamically
19
- const tempSvg = el.append('svg').style('position', 'absolute').style('visibility', 'hidden');
20
- const tempText = tempSvg.append('text').style('font-size', '12px');
21
- let maxLabelWidth = 120;
22
- for (const p of data.projects) {
23
- tempText.text(p.name);
24
- maxLabelWidth = Math.max(maxLabelWidth, tempText.node().getComputedTextLength());
25
- }
26
- tempSvg.remove();
27
- const leftMargin = Math.ceil(maxLabelWidth) + 16;
28
-
29
- const margin = { top: 10, right: 360, bottom: 10, left: leftMargin };
30
- const barHeight = 24;
31
- const gap = 8;
32
- const height = data.projects.length * (barHeight + gap) + margin.top + margin.bottom;
33
- const width = container.clientWidth - margin.left - margin.right;
34
-
35
- const svg = el.append('svg')
36
- .attr('width', width + margin.left + margin.right)
37
- .attr('height', height)
38
- .append('g').attr('transform', `translate(${margin.left},${margin.top})`);
39
-
40
- const x = d3.scaleLinear().domain([0, d3.max(data.projects, d => d.total_tokens)]).range([0, width]);
41
- const y = d3.scaleBand().domain(data.projects.map(d => d.name)).range([0, height - margin.top - margin.bottom]).padding(0.25);
42
-
43
- // Project name labels
44
- svg.selectAll('.project-label').data(data.projects).enter().append('text')
45
- .attr('x', -8).attr('y', d => y(d.name) + y.bandwidth() / 2)
46
- .attr('text-anchor', 'end').attr('dominant-baseline', 'middle')
47
- .style('fill', '#e2e8f0').style('font-size', '12px').text(d => d.name);
48
-
49
- // Stacked bars: cache_read + cache_creation + input + output
50
- const cr = d => d.cache_read_tokens || 0;
51
- const cc = d => d.cache_creation_tokens || 0;
52
-
53
- svg.selectAll('.bar-cache-read').data(data.projects).enter().append('rect')
54
- .attr('x', 0).attr('y', d => y(d.name))
55
- .attr('width', d => x(cr(d))).attr('height', y.bandwidth())
56
- .attr('fill', '#4ade80').attr('opacity', 0.6).attr('rx', 3);
57
-
58
- svg.selectAll('.bar-cache-creation').data(data.projects).enter().append('rect')
59
- .attr('x', d => x(cr(d))).attr('y', d => y(d.name))
60
- .attr('width', d => x(cr(d) + cc(d)) - x(cr(d))).attr('height', y.bandwidth())
61
- .attr('fill', '#f59e0b').attr('opacity', 0.6);
62
-
63
- svg.selectAll('.bar-input').data(data.projects).enter().append('rect')
64
- .attr('x', d => x(cr(d) + cc(d))).attr('y', d => y(d.name))
65
- .attr('width', d => x(cr(d) + cc(d) + d.total_input_tokens) - x(cr(d) + cc(d))).attr('height', y.bandwidth())
66
- .attr('fill', '#3b82f6').attr('opacity', 0.7);
67
-
68
- svg.selectAll('.bar-output').data(data.projects).enter().append('rect')
69
- .attr('x', d => x(cr(d) + cc(d) + d.total_input_tokens)).attr('y', d => y(d.name))
70
- .attr('width', d => x(d.total_tokens) - x(cr(d) + cc(d) + d.total_input_tokens)).attr('height', y.bandwidth())
71
- .attr('fill', '#f97316').attr('opacity', 0.7);
72
-
73
- // Right-side label: tokens + cost
74
- svg.selectAll('.detail-label').data(data.projects).enter().append('text')
75
- .attr('x', d => x(d.total_tokens) + 8).attr('y', d => y(d.name) + y.bandwidth() / 2)
76
- .attr('dominant-baseline', 'middle')
77
- .style('font-size', '11px')
78
- .html(d => {
79
- // Use tspans for colored segments
80
- return '';
81
- })
82
- .each(function(d) {
83
- const text = d3.select(this);
84
- text.append('tspan').style('fill', '#f8fafc').style('font-weight', '600').text(fmt(d.total_tokens));
85
- text.append('tspan').style('fill', '#64748b').text(' (');
86
- text.append('tspan').style('fill', '#4ade80').text(`cr:${fmt(d.cache_read_tokens || 0)}`);
87
- text.append('tspan').style('fill', '#64748b').text('/');
88
- text.append('tspan').style('fill', '#f59e0b').text(`cw:${fmt(d.cache_creation_tokens || 0)}`);
89
- text.append('tspan').style('fill', '#64748b').text('/');
90
- text.append('tspan').style('fill', '#60a5fa').text(`in:${fmt(d.total_input_tokens)}`);
91
- text.append('tspan').style('fill', '#64748b').text('/');
92
- text.append('tspan').style('fill', '#f97316').text(`out:${fmt(d.total_output_tokens)}`);
93
- text.append('tspan').style('fill', '#64748b').text(') ');
94
- text.append('tspan').style('fill', '#f59e0b').style('font-weight', '600').text(`$${d.estimated_cost_usd.toFixed(2)}`);
95
- });
96
-
97
- // Legend
98
- const legend = el.append('div').style('display', 'flex').style('gap', '16px').style('margin-top', '8px');
99
- legend.append('span').style('font-size', '11px').style('color', '#4ade80').html('● Cache Read');
100
- legend.append('span').style('font-size', '11px').style('color', '#f59e0b').html('● Cache Write');
101
- legend.append('span').style('font-size', '11px').style('color', '#60a5fa').html('● Input');
102
- legend.append('span').style('font-size', '11px').style('color', '#f97316').html('● Output');
103
- }
1
+ const COLORS = ['#3b82f6', '#8b5cf6', '#f59e0b', '#4ade80', '#ef4444', '#ec4899', '#06b6d4'];
2
+
3
+ function fmt(n) {
4
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
5
+ if (n >= 1_000) return (n / 1_000).toFixed(0) + 'K';
6
+ return n.toString();
7
+ }
8
+
9
+ export function renderProjectDistribution(container, data) {
10
+ const el = d3.select(container);
11
+ el.selectAll('*').remove();
12
+
13
+ if (!data.projects || data.projects.length === 0) {
14
+ el.append('p').style('color', '#64748b').text('No data');
15
+ return;
16
+ }
17
+
18
+ // Measure longest project name to set left margin dynamically
19
+ const tempSvg = el.append('svg').style('position', 'absolute').style('visibility', 'hidden');
20
+ const tempText = tempSvg.append('text').style('font-size', '12px');
21
+ let maxLabelWidth = 120;
22
+ for (const p of data.projects) {
23
+ tempText.text(p.name);
24
+ maxLabelWidth = Math.max(maxLabelWidth, tempText.node().getComputedTextLength());
25
+ }
26
+ tempSvg.remove();
27
+ const leftMargin = Math.ceil(maxLabelWidth) + 16;
28
+
29
+ const margin = { top: 10, right: 360, bottom: 10, left: leftMargin };
30
+ const barHeight = 24;
31
+ const gap = 8;
32
+ const height = data.projects.length * (barHeight + gap) + margin.top + margin.bottom;
33
+ const width = container.clientWidth - margin.left - margin.right;
34
+
35
+ const svg = el.append('svg')
36
+ .attr('width', width + margin.left + margin.right)
37
+ .attr('height', height)
38
+ .append('g').attr('transform', `translate(${margin.left},${margin.top})`);
39
+
40
+ const x = d3.scaleLinear().domain([0, d3.max(data.projects, d => d.total_tokens)]).range([0, width]);
41
+ const y = d3.scaleBand().domain(data.projects.map(d => d.name)).range([0, height - margin.top - margin.bottom]).padding(0.25);
42
+
43
+ // Project name labels
44
+ svg.selectAll('.project-label').data(data.projects).enter().append('text')
45
+ .attr('x', -8).attr('y', d => y(d.name) + y.bandwidth() / 2)
46
+ .attr('text-anchor', 'end').attr('dominant-baseline', 'middle')
47
+ .style('fill', '#e2e8f0').style('font-size', '12px').text(d => d.name);
48
+
49
+ // Stacked bars: cache_read + cache_creation + input + output
50
+ const cr = d => d.cache_read_tokens || 0;
51
+ const cc = d => d.cache_creation_tokens || 0;
52
+
53
+ svg.selectAll('.bar-cache-read').data(data.projects).enter().append('rect')
54
+ .attr('x', 0).attr('y', d => y(d.name))
55
+ .attr('width', d => x(cr(d))).attr('height', y.bandwidth())
56
+ .attr('fill', '#4ade80').attr('opacity', 0.6).attr('rx', 3);
57
+
58
+ svg.selectAll('.bar-cache-creation').data(data.projects).enter().append('rect')
59
+ .attr('x', d => x(cr(d))).attr('y', d => y(d.name))
60
+ .attr('width', d => x(cr(d) + cc(d)) - x(cr(d))).attr('height', y.bandwidth())
61
+ .attr('fill', '#f59e0b').attr('opacity', 0.6);
62
+
63
+ svg.selectAll('.bar-input').data(data.projects).enter().append('rect')
64
+ .attr('x', d => x(cr(d) + cc(d))).attr('y', d => y(d.name))
65
+ .attr('width', d => x(cr(d) + cc(d) + d.total_input_tokens) - x(cr(d) + cc(d))).attr('height', y.bandwidth())
66
+ .attr('fill', '#3b82f6').attr('opacity', 0.7);
67
+
68
+ svg.selectAll('.bar-output').data(data.projects).enter().append('rect')
69
+ .attr('x', d => x(cr(d) + cc(d) + d.total_input_tokens)).attr('y', d => y(d.name))
70
+ .attr('width', d => x(d.total_tokens) - x(cr(d) + cc(d) + d.total_input_tokens)).attr('height', y.bandwidth())
71
+ .attr('fill', '#f97316').attr('opacity', 0.7);
72
+
73
+ // Right-side label: tokens + cost
74
+ svg.selectAll('.detail-label').data(data.projects).enter().append('text')
75
+ .attr('x', d => x(d.total_tokens) + 8).attr('y', d => y(d.name) + y.bandwidth() / 2)
76
+ .attr('dominant-baseline', 'middle')
77
+ .style('font-size', '11px')
78
+ .html(d => {
79
+ // Use tspans for colored segments
80
+ return '';
81
+ })
82
+ .each(function(d) {
83
+ const text = d3.select(this);
84
+ text.append('tspan').style('fill', '#f8fafc').style('font-weight', '600').text(fmt(d.total_tokens));
85
+ text.append('tspan').style('fill', '#64748b').text(' (');
86
+ text.append('tspan').style('fill', '#4ade80').text(`cr:${fmt(d.cache_read_tokens || 0)}`);
87
+ text.append('tspan').style('fill', '#64748b').text('/');
88
+ text.append('tspan').style('fill', '#f59e0b').text(`cw:${fmt(d.cache_creation_tokens || 0)}`);
89
+ text.append('tspan').style('fill', '#64748b').text('/');
90
+ text.append('tspan').style('fill', '#60a5fa').text(`in:${fmt(d.total_input_tokens)}`);
91
+ text.append('tspan').style('fill', '#64748b').text('/');
92
+ text.append('tspan').style('fill', '#f97316').text(`out:${fmt(d.total_output_tokens)}`);
93
+ text.append('tspan').style('fill', '#64748b').text(') ');
94
+ text.append('tspan').style('fill', '#f59e0b').style('font-weight', '600').text(`$${d.estimated_cost_usd.toFixed(2)}`);
95
+ });
96
+
97
+ // Legend
98
+ const legend = el.append('div').style('display', 'flex').style('gap', '16px').style('margin-top', '8px');
99
+ legend.append('span').style('font-size', '11px').style('color', '#4ade80').html('● Cache Read');
100
+ legend.append('span').style('font-size', '11px').style('color', '#f59e0b').html('● Cache Write');
101
+ legend.append('span').style('font-size', '11px').style('color', '#60a5fa').html('● Input');
102
+ legend.append('span').style('font-size', '11px').style('color', '#f97316').html('● Output');
103
+ }
@@ -1,117 +1,117 @@
1
- function formatTokens(n) {
2
- if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
3
- if (n >= 1_000) return (n / 1_000).toFixed(0) + 'K';
4
- return n.toString();
5
- }
6
-
7
- const MODEL_DISPLAY = {
8
- 'claude-opus-4-6': 'opus 4.6',
9
- 'claude-sonnet-4-6': 'sonnet 4.6',
10
- 'claude-haiku-4-5': 'haiku 4.5',
11
- 'claude-haiku-4-5-20251001': 'haiku 4.5',
12
- };
13
-
14
- function modelTag(model) {
15
- const shortName = MODEL_DISPLAY[model] || model.replace('claude-', '').replace(/-(\d+)-(\d+)/, ' $1.$2');
16
- let cls = 'tag-model-sonnet';
17
- if (model.includes('opus')) cls = 'tag-model-opus';
18
- else if (model.includes('haiku')) cls = 'tag-model-haiku';
19
- return `<span class="tag ${cls}">${shortName}</span>`;
20
- }
21
-
22
- function formatDate(iso) {
23
- const d = new Date(iso);
24
- return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +
25
- ', ' + d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
26
- }
27
-
28
- function formatDuration(minutes) {
29
- if (minutes < 60) return `${minutes}m`;
30
- const h = Math.floor(minutes / 60);
31
- const m = minutes % 60;
32
- return `${h}h ${m}m`;
33
- }
34
-
35
- export function renderSessionTable(container, data, { onSort, onPageChange }) {
36
- container.innerHTML = '';
37
-
38
- const table = document.createElement('table');
39
- const thead = document.createElement('thead');
40
- const headerRow = document.createElement('tr');
41
-
42
- const columns = [
43
- { key: 'date', label: 'Date & Time' },
44
- { key: 'project', label: 'Project' },
45
- { key: 'models', label: 'Model(s)' },
46
- { key: 'input', label: 'Input', align: 'right' },
47
- { key: 'output', label: 'Output', align: 'right' },
48
- { key: 'cache_read', label: 'Cache Read', align: 'right' },
49
- { key: 'cache_creation', label: 'Cache Write', align: 'right' },
50
- { key: 'total', label: 'Total', align: 'right' },
51
- { key: 'cost', label: 'API Cost', align: 'right' },
52
- { key: 'duration', label: 'Duration', align: 'right' },
53
- ];
54
-
55
- for (const col of columns) {
56
- const th = document.createElement('th');
57
- th.textContent = col.label;
58
- if (col.align) th.className = 'align-right';
59
- if (['date', 'cost', 'total'].includes(col.key)) {
60
- th.style.cursor = 'pointer';
61
- th.addEventListener('click', () => {
62
- const sortKey = col.key === 'total' ? 'tokens' : col.key;
63
- onSort(sortKey);
64
- });
65
- }
66
- headerRow.appendChild(th);
67
- }
68
- thead.appendChild(headerRow);
69
- table.appendChild(thead);
70
-
71
- const tbody = document.createElement('tbody');
72
- for (const s of data.sessions) {
73
- const tr = document.createElement('tr');
74
- tr.innerHTML = `
75
- <td>${formatDate(s.startTime)}</td>
76
- <td><span class="tag tag-project">${s.project}</span></td>
77
- <td>${s.models.map(modelTag).join(' ')}</td>
78
- <td class="align-right" style="color:#60a5fa">${formatTokens(s.input_tokens)}</td>
79
- <td class="align-right" style="color:#f97316">${formatTokens(s.output_tokens)}</td>
80
- <td class="align-right" style="color:#4ade80">${formatTokens(s.cache_read_tokens)}</td>
81
- <td class="align-right" style="color:#f59e0b">${formatTokens(s.cache_creation_tokens)}</td>
82
- <td class="align-right" style="font-weight:600">${formatTokens(s.total_tokens)}</td>
83
- <td class="align-right" style="color:#f59e0b;font-weight:600">$${s.estimated_cost_usd.toFixed(2)}</td>
84
- <td class="align-right">${formatDuration(s.duration_minutes)}</td>
85
- `;
86
- tbody.appendChild(tr);
87
- }
88
- table.appendChild(tbody);
89
-
90
- if (data.totals) {
91
- const tfoot = document.createElement('tfoot');
92
- const tr = document.createElement('tr');
93
- tr.innerHTML = `
94
- <td colspan="3">Showing ${data.sessions.length} of ${data.pagination.total_sessions} sessions</td>
95
- <td class="align-right" colspan="4"></td>
96
- <td class="align-right">${formatTokens(data.totals.total_tokens)}</td>
97
- <td class="align-right" style="color:#f59e0b">$${data.totals.estimated_cost_usd.toFixed(2)}</td>
98
- <td></td>
99
- `;
100
- tfoot.appendChild(tr);
101
- table.appendChild(tfoot);
102
- }
103
-
104
- container.appendChild(table);
105
-
106
- const pagEl = document.getElementById('session-pagination');
107
- if (pagEl && data.pagination && data.pagination.total_pages > 1) {
108
- pagEl.innerHTML = '';
109
- for (let i = 1; i <= data.pagination.total_pages; i++) {
110
- const btn = document.createElement('button');
111
- btn.textContent = i;
112
- if (i === data.pagination.page) btn.className = 'active';
113
- btn.addEventListener('click', () => onPageChange(i));
114
- pagEl.appendChild(btn);
115
- }
116
- }
117
- }
1
+ function formatTokens(n) {
2
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
3
+ if (n >= 1_000) return (n / 1_000).toFixed(0) + 'K';
4
+ return n.toString();
5
+ }
6
+
7
+ const MODEL_DISPLAY = {
8
+ 'claude-opus-4-6': 'opus 4.6',
9
+ 'claude-sonnet-4-6': 'sonnet 4.6',
10
+ 'claude-haiku-4-5': 'haiku 4.5',
11
+ 'claude-haiku-4-5-20251001': 'haiku 4.5',
12
+ };
13
+
14
+ function modelTag(model) {
15
+ const shortName = MODEL_DISPLAY[model] || model.replace('claude-', '').replace(/-(\d+)-(\d+)/, ' $1.$2');
16
+ let cls = 'tag-model-sonnet';
17
+ if (model.includes('opus')) cls = 'tag-model-opus';
18
+ else if (model.includes('haiku')) cls = 'tag-model-haiku';
19
+ return `<span class="tag ${cls}">${shortName}</span>`;
20
+ }
21
+
22
+ function formatDate(iso) {
23
+ const d = new Date(iso);
24
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +
25
+ ', ' + d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
26
+ }
27
+
28
+ function formatDuration(minutes) {
29
+ if (minutes < 60) return `${minutes}m`;
30
+ const h = Math.floor(minutes / 60);
31
+ const m = minutes % 60;
32
+ return `${h}h ${m}m`;
33
+ }
34
+
35
+ export function renderSessionTable(container, data, { onSort, onPageChange }) {
36
+ container.innerHTML = '';
37
+
38
+ const table = document.createElement('table');
39
+ const thead = document.createElement('thead');
40
+ const headerRow = document.createElement('tr');
41
+
42
+ const columns = [
43
+ { key: 'date', label: 'Date & Time' },
44
+ { key: 'project', label: 'Project' },
45
+ { key: 'models', label: 'Model(s)' },
46
+ { key: 'input', label: 'Input', align: 'right' },
47
+ { key: 'output', label: 'Output', align: 'right' },
48
+ { key: 'cache_read', label: 'Cache Read', align: 'right' },
49
+ { key: 'cache_creation', label: 'Cache Write', align: 'right' },
50
+ { key: 'total', label: 'Total', align: 'right' },
51
+ { key: 'cost', label: 'API Cost', align: 'right' },
52
+ { key: 'duration', label: 'Duration', align: 'right' },
53
+ ];
54
+
55
+ for (const col of columns) {
56
+ const th = document.createElement('th');
57
+ th.textContent = col.label;
58
+ if (col.align) th.className = 'align-right';
59
+ if (['date', 'cost', 'total'].includes(col.key)) {
60
+ th.style.cursor = 'pointer';
61
+ th.addEventListener('click', () => {
62
+ const sortKey = col.key === 'total' ? 'tokens' : col.key;
63
+ onSort(sortKey);
64
+ });
65
+ }
66
+ headerRow.appendChild(th);
67
+ }
68
+ thead.appendChild(headerRow);
69
+ table.appendChild(thead);
70
+
71
+ const tbody = document.createElement('tbody');
72
+ for (const s of data.sessions) {
73
+ const tr = document.createElement('tr');
74
+ tr.innerHTML = `
75
+ <td>${formatDate(s.startTime)}</td>
76
+ <td><span class="tag tag-project">${s.project}</span></td>
77
+ <td>${s.models.map(modelTag).join(' ')}</td>
78
+ <td class="align-right" style="color:#60a5fa">${formatTokens(s.input_tokens)}</td>
79
+ <td class="align-right" style="color:#f97316">${formatTokens(s.output_tokens)}</td>
80
+ <td class="align-right" style="color:#4ade80">${formatTokens(s.cache_read_tokens)}</td>
81
+ <td class="align-right" style="color:#f59e0b">${formatTokens(s.cache_creation_tokens)}</td>
82
+ <td class="align-right" style="font-weight:600">${formatTokens(s.total_tokens)}</td>
83
+ <td class="align-right" style="color:#f59e0b;font-weight:600">$${s.estimated_cost_usd.toFixed(2)}</td>
84
+ <td class="align-right">${formatDuration(s.duration_minutes)}</td>
85
+ `;
86
+ tbody.appendChild(tr);
87
+ }
88
+ table.appendChild(tbody);
89
+
90
+ if (data.totals) {
91
+ const tfoot = document.createElement('tfoot');
92
+ const tr = document.createElement('tr');
93
+ tr.innerHTML = `
94
+ <td colspan="3">Showing ${data.sessions.length} of ${data.pagination.total_sessions} sessions</td>
95
+ <td class="align-right" colspan="4"></td>
96
+ <td class="align-right">${formatTokens(data.totals.total_tokens)}</td>
97
+ <td class="align-right" style="color:#f59e0b">$${data.totals.estimated_cost_usd.toFixed(2)}</td>
98
+ <td></td>
99
+ `;
100
+ tfoot.appendChild(tr);
101
+ table.appendChild(tfoot);
102
+ }
103
+
104
+ container.appendChild(table);
105
+
106
+ const pagEl = document.getElementById('session-pagination');
107
+ if (pagEl && data.pagination && data.pagination.total_pages > 1) {
108
+ pagEl.innerHTML = '';
109
+ for (let i = 1; i <= data.pagination.total_pages; i++) {
110
+ const btn = document.createElement('button');
111
+ btn.textContent = i;
112
+ if (i === data.pagination.page) btn.className = 'active';
113
+ btn.addEventListener('click', () => onPageChange(i));
114
+ pagEl.appendChild(btn);
115
+ }
116
+ }
117
+ }