claude-usage-dashboard 1.2.2 → 1.3.0

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.
package/README.md CHANGED
@@ -1,66 +1,67 @@
1
- # Claude Usage Dashboard
2
-
3
- [![npm version](https://img.shields.io/npm/v/claude-usage-dashboard)](https://www.npmjs.com/package/claude-usage-dashboard)
4
-
5
- A self-hosted dashboard that visualizes your [Claude Code](https://claude.ai/code) usage by parsing local JSONL session logs from `~/.claude/projects/`.
6
-
7
- ![Dashboard Screenshot](docs/screenshots/dashboard.png)
8
-
9
- ## Features
10
-
11
- - **Token tracking** — Total tokens with breakdown by input, output, cache read, and cache write
12
- - **Cost estimation** — API cost equivalent at standard pricing, compared against your subscription plan (Pro / Max 5x / Max 20x)
13
- - **Subscription quota** — Real-time utilization gauges (5-hour, 7-day, per-model) pulled from the Anthropic API with auto-detection of your plan tier
14
- - **Token consumption trend** — Stacked bar chart with hourly, daily, weekly, or monthly granularity
15
- - **Model distribution** — Donut chart showing usage across Claude models
16
- - **Cache efficiency** — Visual breakdown of cache read, cache creation, and uncached requests
17
- - **Project distribution** — Horizontal bar chart comparing token usage across projects
18
- - **Session details** — Sortable, paginated table of every session with cost and duration
19
- - **Auto-refresh** — Dashboard polls every 30s for new usage data; quota refreshes every 2 minutes
20
-
21
- ## Quick Start
22
-
23
- Run directly without installing:
24
-
25
- ```bash
26
- npx claude-usage-dashboard
27
- ```
28
-
29
- Open http://localhost:3000 in your browser.
30
-
31
- ### From Source
32
-
33
- ```bash
34
- git clone https://github.com/ludengz/claudeUsageDashboard.git
35
- cd claudeUsageDashboard
36
- npm install
37
- npm start
38
- ```
39
-
40
- ### Custom Port
41
-
42
- ```bash
43
- PORT=8080 npx claude-usage-dashboard
44
- ```
45
-
46
- ## How It Works
47
-
48
- The dashboard reads Claude Code session logs from `~/.claude/projects/` — if you use Claude Code, these already exist on your machine. Logs are automatically re-read every 5 seconds, and new usage appears without restarting the server.
49
-
50
- Subscription quota data is fetched from the Anthropic API using your local OAuth credentials (`~/.claude/.credentials.json`). Your plan tier (Pro / Max 5x / Max 20x) is auto-detected from the same file.
51
-
52
- ## Tech Stack
53
-
54
- - **Backend:** Node.js, Express 5
55
- - **Frontend:** Vanilla JS (ES modules), D3.js v7
56
- - **Tests:** Mocha + Chai
57
-
58
- ## Running Tests
59
-
60
- ```bash
61
- npm test
62
- ```
63
-
64
- ## License
65
-
66
- ISC
1
+ # Claude Usage Dashboard
2
+
3
+ [![npm version](https://img.shields.io/npm/v/claude-usage-dashboard)](https://www.npmjs.com/package/claude-usage-dashboard)
4
+
5
+ A self-hosted dashboard that visualizes your [Claude Code](https://claude.ai/code) usage by parsing local JSONL session logs from `~/.claude/projects/`.
6
+
7
+ ![Dashboard Screenshot](docs/screenshots/dashboard.png)
8
+
9
+ ## Features
10
+
11
+ - **Token tracking** — Total tokens with breakdown by input, output, cache read, and cache write
12
+ - **Cost estimation** — API cost equivalent at standard pricing, compared against your subscription plan (Pro / Max 5x / Max 20x)
13
+ - **Subscription quota** — Real-time utilization gauges (5-hour, 7-day, per-model) pulled from the Anthropic API with auto-detection of your plan tier. 7-day reset shows full date+time; all timestamps include timezone
14
+ - **Token consumption trend** — Stacked bar chart with hourly, daily, weekly, or monthly granularity. Toggle between tokens and dollar view. Includes period summary with avg/min/max stats, active hours heatmap, and smart date range limits per granularity
15
+ - **Model distribution** — Donut chart showing usage across Claude models
16
+ - **Cache efficiency** — Visual breakdown of cache read, cache creation, and uncached requests
17
+ - **Project distribution** — Horizontal bar chart comparing token usage across projects
18
+ - **Session details** — Sortable, paginated table of every session with cost and duration
19
+ - **Auto-refresh** — Dashboard polls every 30s for new usage data; quota refreshes every 2 minutes
20
+ - **Persistent settings** — Date range, granularity, auto-refresh, and y-axis mode are remembered across sessions via localStorage
21
+
22
+ ## Quick Start
23
+
24
+ Run directly without installing:
25
+
26
+ ```bash
27
+ npx claude-usage-dashboard
28
+ ```
29
+
30
+ Open http://localhost:3000 in your browser.
31
+
32
+ ### From Source
33
+
34
+ ```bash
35
+ git clone https://github.com/ludengz/claudeUsageDashboard.git
36
+ cd claudeUsageDashboard
37
+ npm install
38
+ npm start
39
+ ```
40
+
41
+ ### Custom Port
42
+
43
+ ```bash
44
+ PORT=8080 npx claude-usage-dashboard
45
+ ```
46
+
47
+ ## How It Works
48
+
49
+ The dashboard reads Claude Code session logs from `~/.claude/projects/` — if you use Claude Code, these already exist on your machine. Logs are automatically re-read every 5 seconds, and new usage appears without restarting the server.
50
+
51
+ Subscription quota data is fetched from the Anthropic API using your local OAuth credentials (`~/.claude/.credentials.json`). Your plan tier (Pro / Max 5x / Max 20x) is auto-detected from the same file.
52
+
53
+ ## Tech Stack
54
+
55
+ - **Backend:** Node.js, Express 5
56
+ - **Frontend:** Vanilla JS (ES modules), D3.js v7
57
+ - **Tests:** Mocha + Chai
58
+
59
+ ## Running Tests
60
+
61
+ ```bash
62
+ npm test
63
+ ```
64
+
65
+ ## License
66
+
67
+ ISC
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-usage-dashboard",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "description": "Dashboard that visualizes Claude Code usage from local session logs",
5
5
  "main": "server/index.js",
6
6
  "bin": {
@@ -106,6 +106,11 @@ body {
106
106
  .granularity-toggle button.active {
107
107
  background: var(--blue);
108
108
  color: white;
109
+ box-shadow: 0 0 0 1px rgba(59,130,246,0.5);
110
+ }
111
+ .granularity-toggle button:disabled {
112
+ opacity: 0.35;
113
+ cursor: not-allowed;
109
114
  }
110
115
 
111
116
  .table-controls { display: flex; gap: 8px; }
package/public/index.html CHANGED
@@ -57,9 +57,13 @@
57
57
  <section class="chart-section">
58
58
  <div class="chart-header">
59
59
  <h2>Token Consumption Trend</h2>
60
+ <div class="granularity-toggle" id="yaxis-toggle">
61
+ <button data-yaxis="tokens">Tokens</button>
62
+ <button data-yaxis="dollars">$</button>
63
+ </div>
60
64
  <div class="granularity-toggle" id="granularity-toggle">
61
65
  <button data-granularity="hourly">Hourly</button>
62
- <button data-granularity="daily" class="active">Daily</button>
66
+ <button data-granularity="daily">Daily</button>
63
67
  <button data-granularity="weekly">Weekly</button>
64
68
  <button data-granularity="monthly">Monthly</button>
65
69
  </div>
package/public/js/app.js CHANGED
@@ -13,11 +13,12 @@ const state = {
13
13
  dateRange: { from: null, to: null },
14
14
  plan: { plan: 'max20x', customPrice: null },
15
15
  granularity: localStorage.getItem('selectedGranularity') || 'hourly',
16
+ trendYAxis: localStorage.getItem('trendYAxis') || 'tokens',
16
17
  sessionSort: 'date',
17
18
  sessionOrder: 'desc',
18
19
  sessionPage: 1,
19
20
  sessionProject: '',
20
- autoRefresh: true,
21
+ autoRefresh: localStorage.getItem('autoRefresh') !== 'false',
21
22
  autoRefreshInterval: 30,
22
23
  _refreshTimer: null,
23
24
  quotaRefreshInterval: 120,
@@ -36,16 +37,22 @@ function updateLastUpdated() {
36
37
  const el = document.getElementById('last-updated');
37
38
  if (el) {
38
39
  const now = new Date();
39
- el.textContent = `Updated ${now.toLocaleTimeString()}`;
40
+ el.textContent = `Updated ${now.toLocaleTimeString()} ${getTimezoneAbbr()}`;
40
41
  }
41
42
  }
42
43
 
44
+ function getTimezoneAbbr() {
45
+ const parts = new Intl.DateTimeFormat(undefined, { timeZoneName: 'short' }).formatToParts(new Date());
46
+ const tz = parts.find(p => p.type === 'timeZoneName');
47
+ return tz ? tz.value : '';
48
+ }
49
+
43
50
  async function loadQuota() {
44
51
  try {
45
52
  const data = await fetchQuota();
46
53
  renderQuotaGauges(document.getElementById('chart-quota'), data);
47
54
  const el = document.getElementById('quota-last-updated');
48
- if (el && data.lastFetched) el.textContent = `Updated ${new Date(data.lastFetched).toLocaleTimeString()}`;
55
+ if (el && data.lastFetched) el.textContent = `Updated ${new Date(data.lastFetched).toLocaleTimeString()} ${getTimezoneAbbr()}`;
49
56
  } catch { /* silently degrade */ }
50
57
  }
51
58
 
@@ -109,12 +116,12 @@ async function loadAll() {
109
116
 
110
117
  // Set active granularity button
111
118
  const activeGran = usage.granularity;
112
- document.querySelectorAll('.granularity-toggle button').forEach(btn => {
119
+ document.querySelectorAll('#granularity-toggle button').forEach(btn => {
113
120
  btn.classList.toggle('active', btn.dataset.granularity === activeGran);
114
121
  });
115
122
 
116
123
  // Charts
117
- renderTokenTrend(document.getElementById('chart-token-trend'), usage);
124
+ renderTokenTrend(document.getElementById('chart-token-trend'), usage, { yAxis: state.trendYAxis });
118
125
  renderCostComparison(document.getElementById('chart-cost-comparison'), cost);
119
126
  renderModelDistribution(document.getElementById('chart-model-distribution'), models);
120
127
  renderCacheEfficiency(document.getElementById('chart-cache-efficiency'), cache);
@@ -139,13 +146,47 @@ async function loadAll() {
139
146
  updateLastUpdated();
140
147
  }
141
148
 
149
+ // Max bucket limits per granularity to avoid crashing the browser
150
+ const GRANULARITY_MAX_DAYS = { hourly: 14, daily: 90, weekly: 365, monthly: 1825 };
151
+
152
+ function updateGranularityButtons() {
153
+ const { from, to } = state.dateRange;
154
+ const days = (from && to) ? (new Date(to) - new Date(from)) / (1000 * 60 * 60 * 24) : 30;
155
+ document.querySelectorAll('#granularity-toggle button').forEach(btn => {
156
+ const gran = btn.dataset.granularity;
157
+ const maxDays = GRANULARITY_MAX_DAYS[gran] || 9999;
158
+ const tooLarge = days > maxDays;
159
+ btn.disabled = tooLarge;
160
+ btn.title = tooLarge ? `Range too large for ${gran} view (max ${maxDays} days)` : '';
161
+ });
162
+ // If currently selected granularity is now disabled, switch to the finest available
163
+ const currentBtn = document.querySelector(`#granularity-toggle button[data-granularity="${state.granularity}"]`);
164
+ if (currentBtn && currentBtn.disabled) {
165
+ const order = ['hourly', 'daily', 'weekly', 'monthly'];
166
+ const available = order.find(g => {
167
+ const b = document.querySelector(`#granularity-toggle button[data-granularity="${g}"]`);
168
+ return b && !b.disabled;
169
+ });
170
+ if (available) {
171
+ state.granularity = available;
172
+ localStorage.setItem('selectedGranularity', state.granularity);
173
+ }
174
+ }
175
+ // Update active class
176
+ document.querySelectorAll('#granularity-toggle button').forEach(btn => {
177
+ btn.classList.toggle('active', btn.dataset.granularity === state.granularity);
178
+ });
179
+ }
180
+
142
181
  function init() {
143
182
  datePicker = initDatePicker(document.getElementById('date-picker'), (range) => {
144
183
  state.dateRange = range;
145
184
  state.sessionPage = 1;
185
+ updateGranularityButtons();
146
186
  loadAll();
147
187
  });
148
188
  state.dateRange = datePicker.getRange();
189
+ updateGranularityButtons();
149
190
 
150
191
  planSelector = initPlanSelector(document.getElementById('plan-selector'), (plan) => {
151
192
  state.plan = plan;
@@ -153,13 +194,29 @@ function init() {
153
194
  });
154
195
 
155
196
  document.getElementById('granularity-toggle').addEventListener('click', (e) => {
156
- if (e.target.tagName === 'BUTTON') {
197
+ if (e.target.tagName === 'BUTTON' && !e.target.disabled) {
157
198
  state.granularity = e.target.dataset.granularity;
158
199
  localStorage.setItem('selectedGranularity', state.granularity);
159
200
  loadAll();
160
201
  }
161
202
  });
162
203
 
204
+ // Y-axis toggle (tokens / dollars)
205
+ const yaxisToggle = document.getElementById('yaxis-toggle');
206
+ yaxisToggle.querySelectorAll('button').forEach(btn => {
207
+ btn.classList.toggle('active', btn.dataset.yaxis === state.trendYAxis);
208
+ });
209
+ yaxisToggle.addEventListener('click', (e) => {
210
+ if (e.target.tagName === 'BUTTON') {
211
+ state.trendYAxis = e.target.dataset.yaxis;
212
+ localStorage.setItem('trendYAxis', state.trendYAxis);
213
+ yaxisToggle.querySelectorAll('button').forEach(btn => {
214
+ btn.classList.toggle('active', btn.dataset.yaxis === state.trendYAxis);
215
+ });
216
+ loadAll();
217
+ }
218
+ });
219
+
163
220
  const filterInput = document.getElementById('session-filter');
164
221
  let filterTimeout;
165
222
  filterInput.addEventListener('input', () => {
@@ -181,8 +238,10 @@ function init() {
181
238
  document.getElementById('btn-refresh').addEventListener('click', () => { loadAll(); loadQuota(); });
182
239
 
183
240
  const autoToggle = document.getElementById('auto-refresh-toggle');
241
+ autoToggle.checked = state.autoRefresh;
184
242
  autoToggle.addEventListener('change', () => {
185
243
  state.autoRefresh = autoToggle.checked;
244
+ localStorage.setItem('autoRefresh', state.autoRefresh);
186
245
  if (state.autoRefresh) {
187
246
  startAutoRefresh();
188
247
  } else {
@@ -67,7 +67,17 @@ export function renderQuotaGauges(container, data) {
67
67
  if (item.extraDetail) {
68
68
  sub.textContent = item.extraDetail;
69
69
  } else if (item.resets_at) {
70
- sub.textContent = `Resets ${new Date(item.resets_at).toLocaleTimeString()}`;
70
+ const resetDate = new Date(item.resets_at);
71
+ const now = new Date();
72
+ const isToday = resetDate.getFullYear() === now.getFullYear()
73
+ && resetDate.getMonth() === now.getMonth()
74
+ && resetDate.getDate() === now.getDate();
75
+ const tzParts = new Intl.DateTimeFormat(undefined, { timeZoneName: 'short' }).formatToParts(resetDate);
76
+ const tz = tzParts.find(p => p.type === 'timeZoneName')?.value || '';
77
+ const resetStr = isToday
78
+ ? resetDate.toLocaleTimeString()
79
+ : resetDate.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', second: '2-digit' });
80
+ sub.textContent = `Resets ${resetStr} ${tz}`;
71
81
  }
72
82
  cell.appendChild(sub);
73
83
 
@@ -1,6 +1,7 @@
1
1
  // d3 is loaded as a global via <script> tag in index.html
2
2
 
3
- export function renderTokenTrend(container, data) {
3
+ export function renderTokenTrend(container, data, opts = {}) {
4
+ const showDollars = opts.yAxis === 'dollars';
4
5
  const el = d3.select(container);
5
6
  el.selectAll('*').remove();
6
7
 
@@ -9,7 +10,7 @@ export function renderTokenTrend(container, data) {
9
10
  return;
10
11
  }
11
12
 
12
- const margin = { top: 20, right: 30, bottom: 40, left: 60 };
13
+ const margin = { top: 20, right: 30, bottom: 60, left: 60 };
13
14
  const width = container.clientWidth - margin.left - margin.right;
14
15
  const height = 250 - margin.top - margin.bottom;
15
16
 
@@ -19,7 +20,37 @@ export function renderTokenTrend(container, data) {
19
20
  .append('g')
20
21
  .attr('transform', `translate(${margin.left},${margin.top})`);
21
22
 
22
- const buckets = data.buckets;
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
+
23
54
  const x = d3.scaleBand()
24
55
  .domain(buckets.map(d => d.time))
25
56
  .range([0, width])
@@ -27,71 +58,106 @@ export function renderTokenTrend(container, data) {
27
58
 
28
59
  // Helper to get total height for each bucket
29
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;
30
63
 
31
- const maxVal = d3.max(buckets, totalOf);
64
+ const maxVal = d3.max(buckets, valueOf) || 1;
32
65
  const y = d3.scaleLinear().domain([0, maxVal * 1.1]).range([height, 0]);
33
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
+ };
34
85
  const xAxis = svg.append('g')
35
86
  .attr('transform', `translate(0,${height})`)
36
- .call(d3.axisBottom(x).tickValues(x.domain().filter((_, i) => i % Math.ceil(buckets.length / 10) === 0)));
87
+ .call(d3.axisBottom(x).tickValues(tickVals).tickFormat(formatTick));
37
88
  xAxis.selectAll('text').style('fill', '#64748b').style('font-size', '10px')
38
89
  .attr('transform', 'rotate(-45)').attr('text-anchor', 'end');
39
90
  xAxis.selectAll('line, path').style('stroke', '#334155');
40
91
 
41
- const yAxis = svg.append('g').call(d3.axisLeft(y).ticks(5).tickFormat(d3.format('.2s')));
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));
42
94
  yAxis.selectAll('text').style('fill', '#64748b').style('font-size', '10px');
43
95
  yAxis.selectAll('line, path').style('stroke', '#334155');
44
96
 
45
- // Stack order (bottom to top): cache_read, cache_creation, input, output
46
- // Cache read (bottom)
47
- svg.selectAll('.bar-cache-read')
48
- .data(buckets)
49
- .enter().append('rect')
50
- .attr('x', d => x(d.time))
51
- .attr('y', d => y(d.cache_read_tokens || 0))
52
- .attr('width', x.bandwidth())
53
- .attr('height', d => height - y(d.cache_read_tokens || 0))
54
- .attr('fill', '#4ade80')
55
- .attr('opacity', 0.6);
56
-
57
- // Cache creation (on top of cache read)
58
- const cacheBase = d => (d.cache_read_tokens || 0);
59
- svg.selectAll('.bar-cache-creation')
60
- .data(buckets)
61
- .enter().append('rect')
62
- .attr('x', d => x(d.time))
63
- .attr('y', d => y(cacheBase(d) + (d.cache_creation_tokens || 0)))
64
- .attr('width', x.bandwidth())
65
- .attr('height', d => y(cacheBase(d)) - y(cacheBase(d) + (d.cache_creation_tokens || 0)))
66
- .attr('fill', '#f59e0b')
67
- .attr('opacity', 0.6);
68
-
69
- // Input (on top of cache)
70
- const inputBase = d => cacheBase(d) + (d.cache_creation_tokens || 0);
71
- svg.selectAll('.bar-input')
72
- .data(buckets)
73
- .enter().append('rect')
74
- .attr('x', d => x(d.time))
75
- .attr('y', d => y(inputBase(d) + d.input_tokens))
76
- .attr('width', x.bandwidth())
77
- .attr('height', d => y(inputBase(d)) - y(inputBase(d) + d.input_tokens))
78
- .attr('fill', '#3b82f6')
79
- .attr('opacity', 0.7);
80
-
81
- // Output (top)
82
- const outputBase = d => inputBase(d) + d.input_tokens;
83
- svg.selectAll('.bar-output')
84
- .data(buckets)
85
- .enter().append('rect')
86
- .attr('x', d => x(d.time))
87
- .attr('y', d => y(outputBase(d) + d.output_tokens))
88
- .attr('width', x.bandwidth())
89
- .attr('height', d => y(outputBase(d)) - y(outputBase(d) + d.output_tokens))
90
- .attr('fill', '#f97316')
91
- .attr('opacity', 0.7);
92
-
93
- // Tooltip
94
- const tooltip = d3.select('body').append('div').attr('class', 'd3-tooltip').style('display', 'none');
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');
95
161
 
96
162
  svg.selectAll('rect')
97
163
  .on('mouseover', (event, d) => {
@@ -105,9 +171,187 @@ export function renderTokenTrend(container, data) {
105
171
  })
106
172
  .on('mouseout', () => tooltip.style('display', 'none'));
107
173
 
108
- const legend = el.append('div').style('display', 'flex').style('gap', '16px').style('margin-top', '8px');
109
- legend.append('span').style('font-size', '11px').style('color', '#4ade80').html('● Cache Read');
110
- legend.append('span').style('font-size', '11px').style('color', '#f59e0b').html('● Cache Write');
111
- legend.append('span').style('font-size', '11px').style('color', '#60a5fa').html('● Input');
112
- legend.append('span').style('font-size', '11px').style('color', '#f97316').html('● Output');
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
+ }
113
357
  }
@@ -4,18 +4,32 @@ export function initDatePicker(container, onChange) {
4
4
  thirtyDaysAgo.setDate(today.getDate() - 30);
5
5
  const fmt = d => d.toISOString().slice(0, 10);
6
6
 
7
+ const savedFrom = localStorage.getItem('datePickerFrom') || fmt(thirtyDaysAgo);
8
+ const savedTo = localStorage.getItem('datePickerTo') || fmt(today);
9
+
7
10
  container.innerHTML = `
8
11
  <span>📅</span>
9
- <input type="date" id="date-from" value="${fmt(thirtyDaysAgo)}">
12
+ <input type="date" id="date-from" value="${savedFrom}">
10
13
  <span>–</span>
11
- <input type="date" id="date-to" value="${fmt(today)}">
14
+ <input type="date" id="date-to" value="${savedTo}">
12
15
  `;
13
16
 
14
17
  const fromInput = container.querySelector('#date-from');
15
18
  const toInput = container.querySelector('#date-to');
16
- const emitChange = () => onChange({ from: fromInput.value, to: toInput.value });
19
+ const emitChange = () => {
20
+ localStorage.setItem('datePickerFrom', fromInput.value);
21
+ localStorage.setItem('datePickerTo', toInput.value);
22
+ onChange({ from: fromInput.value, to: toInput.value });
23
+ };
17
24
  fromInput.addEventListener('change', emitChange);
18
25
  toInput.addEventListener('change', emitChange);
19
26
 
20
- return { getRange: () => ({ from: fromInput.value, to: toInput.value }) };
27
+ return {
28
+ getRange: () => ({ from: fromInput.value, to: toInput.value }),
29
+ setRange: (from, to) => {
30
+ fromInput.value = from;
31
+ toInput.value = to;
32
+ emitChange();
33
+ },
34
+ };
21
35
  }
package/server/parser.js CHANGED
@@ -2,21 +2,38 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
 
4
4
  export function deriveProjectName(dirName) {
5
- const clean = dirName.startsWith('-') ? dirName.slice(1) : dirName;
6
-
7
- const workspaceIdx = clean.indexOf('-Workspace-');
8
- if (workspaceIdx !== -1) {
9
- return clean.slice(workspaceIdx + '-Workspace-'.length);
5
+ // Strip drive prefix like "C--" at the start
6
+ const clean = dirName.replace(/^[A-Za-z]--/, '');
7
+
8
+ // Known parent directory markers (case-insensitive search)
9
+ // Match the last occurrence of common parent dirs to get the project folder name
10
+ const lower = clean.toLowerCase();
11
+ const markers = ['-workspace-', '-projects-', '-repos-', '-src-', '-home-', '-desktop-', '-documents-', '-downloads-'];
12
+ let bestIdx = -1;
13
+ let bestLen = 0;
14
+ for (const m of markers) {
15
+ const idx = lower.lastIndexOf(m);
16
+ if (idx > bestIdx) {
17
+ bestIdx = idx;
18
+ bestLen = m.length;
19
+ }
20
+ }
21
+ if (bestIdx !== -1) {
22
+ const result = clean.slice(bestIdx + bestLen);
23
+ // Handle worktree subdirs: "project--claude-worktrees-branch-name" → "project"
24
+ const wtIdx = result.indexOf('--claude-worktrees');
25
+ return wtIdx !== -1 ? result.slice(0, wtIdx) : result;
10
26
  }
11
27
 
12
- const homeIdx = clean.indexOf('-Home-');
13
- if (homeIdx !== -1) {
14
- const rest = clean.slice(homeIdx + '-Home-'.length);
15
- return rest;
28
+ // Fallback: strip Users-username prefix, return the rest
29
+ const userMatch = clean.match(/^Users-[^-]+-(.+)$/);
30
+ if (userMatch) {
31
+ const rest = userMatch[1];
32
+ const wtIdx = rest.indexOf('--claude-worktrees');
33
+ return wtIdx !== -1 ? rest.slice(0, wtIdx) : rest;
16
34
  }
17
35
 
18
- const parts = clean.split('-');
19
- return parts[parts.length - 1];
36
+ return clean;
20
37
  }
21
38
 
22
39
  export function parseLogFile(filePath) {