claude-roi 0.8.6 → 0.8.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-roi",
3
- "version": "0.8.6",
3
+ "version": "0.8.8",
4
4
  "description": "Correlate AI coding agent token usage with git output to measure ROI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2245,6 +2245,8 @@ let sortCol = 'startTime';
2245
2245
  let sortOrder = -1;
2246
2246
  let timelineChart = null;
2247
2247
  let timelineLogScale = false;
2248
+ let tokenBurnChart = null;
2249
+ let tokenBurnPercentMode = false;
2248
2250
 
2249
2251
  document.addEventListener('DOMContentLoaded', async () => {
2250
2252
  initTheme();
@@ -2308,6 +2310,7 @@ function render() {
2308
2310
  <div class="chart-card full-width">
2309
2311
  <div class="chart-header">
2310
2312
  <h3>Token Burn Rate <i class="info-tip" data-tip="Daily token consumption stacked by type (input/output/cache). Dashed orange line shows cumulative total — watch it climb.">i</i></h3>
2313
+ <button class="scale-toggle" onclick="toggleTokenBurnPercent()">Split view</button>
2311
2314
  </div>
2312
2315
  <div class="chart-container"><canvas id="chart-token-burn"></canvas></div>
2313
2316
  </div>
@@ -2327,8 +2330,8 @@ function render() {
2327
2330
  <div class="chart-container"><canvas id="chart-tools"></canvas></div>
2328
2331
  </div>
2329
2332
  <div class="chart-card">
2330
- <h3>Session Length vs Efficiency <i class="info-tip" data-tip="Sessions grouped by message count (1-50, 51-100, etc). Blue bars = average cost per commit for that group. Purple bars = number of sessions. Find your sweet spot the group with the lowest blue bar.">i</i></h3>
2331
- <div class="chart-container"><canvas id="chart-buckets"></canvas></div>
2333
+ <h3>Cost per Commit by Model <i class="info-tip" data-tip="Compare how much each model costs per commit. Lower blue bars = better value. Purple bars show commit volume for confidence. Use this to decide which model to use for different tasks.">i</i></h3>
2334
+ <div class="chart-container"><canvas id="chart-model-efficiency"></canvas></div>
2332
2335
  </div>
2333
2336
  <div class="chart-card">
2334
2337
  <h3>Productivity Heatmap <i class="info-tip" data-tip="Each cell = commits produced during that hour and day of week. Darker green = more commits. Find your peak productivity windows.">i</i></h3>
@@ -2891,7 +2894,7 @@ function initCharts() {
2891
2894
  let cumulative = 0;
2892
2895
  const cumulativeData = dailyTokens.map(d => { cumulative += (d.totalTokens || 0); return cumulative; });
2893
2896
 
2894
- new Chart(document.getElementById('chart-token-burn'), {
2897
+ tokenBurnChart = new Chart(document.getElementById('chart-token-burn'), {
2895
2898
  type: 'bar',
2896
2899
  data: {
2897
2900
  labels: dailyTokens.map(d => new Date(d.date + 'T12:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' })),
@@ -3123,25 +3126,29 @@ function initCharts() {
3123
3126
  },
3124
3127
  });
3125
3128
 
3126
- // Session buckets
3127
- const buckets = DATA.sessionBuckets;
3128
- const bucketLabels = Object.keys(buckets);
3129
- new Chart(document.getElementById('chart-buckets'), {
3129
+ // Cost per Commit by Model
3130
+ const modelEff = DATA.modelBreakdown;
3131
+ const modelEffLabels = Object.keys(modelEff).filter(k => modelEff[k].commits > 0);
3132
+ const modelEffColors = { opus: '#a855f7', sonnet: '#3b82f6', haiku: '#22d3a8', unknown: '#94a3b8' };
3133
+ new Chart(document.getElementById('chart-model-efficiency'), {
3130
3134
  type: 'bar',
3131
3135
  data: {
3132
- labels: bucketLabels,
3136
+ labels: modelEffLabels.map(m => m.charAt(0).toUpperCase() + m.slice(1) + ' ($' + modelEff[m].cost.toFixed(0) + ' total)'),
3133
3137
  datasets: [
3134
3138
  {
3135
3139
  label: 'Avg $/Commit',
3136
- data: bucketLabels.map(b => buckets[b].avgCostPerCommit),
3137
- backgroundColor: '#3b82f6',
3140
+ data: modelEffLabels.map(m => modelEff[m].avgCostPerCommit),
3141
+ backgroundColor: modelEffLabels.map(m => modelEffColors[m] || '#94a3b8'),
3138
3142
  borderRadius: 4,
3139
3143
  yAxisID: 'y',
3140
3144
  },
3141
3145
  {
3142
- label: 'Sessions',
3143
- data: bucketLabels.map(b => buckets[b].sessions),
3144
- backgroundColor: 'rgba(168,85,247,0.4)',
3146
+ label: 'Commits',
3147
+ data: modelEffLabels.map(m => Math.round(modelEff[m].commits)),
3148
+ backgroundColor: modelEffLabels.map(m => {
3149
+ const c = modelEffColors[m] || '#94a3b8';
3150
+ return c + '40';
3151
+ }),
3145
3152
  borderRadius: 4,
3146
3153
  yAxisID: 'y1',
3147
3154
  },
@@ -3150,10 +3157,30 @@ function initCharts() {
3150
3157
  options: {
3151
3158
  responsive: true,
3152
3159
  maintainAspectRatio: false,
3153
- plugins: { legend: { position: 'top' } },
3160
+ plugins: {
3161
+ legend: { position: 'top' },
3162
+ tooltip: {
3163
+ callbacks: {
3164
+ label: ctx => {
3165
+ if (ctx.datasetIndex === 0) return ` Avg $/Commit: $${ctx.parsed.y.toFixed(2)}`;
3166
+ return ` Commits: ${ctx.parsed.y}`;
3167
+ },
3168
+ afterBody: (items) => {
3169
+ const idx = items[0].dataIndex;
3170
+ const m = modelEffLabels[idx];
3171
+ const d = modelEff[m];
3172
+ const lines = [];
3173
+ lines.push(`Sessions: ${d.sessions}`);
3174
+ lines.push(`Total: $${d.cost.toFixed(2)} | ${formatTokens(d.tokens)} tokens`);
3175
+ if (d.tokensPerCommit) lines.push(`${formatTokens(d.tokensPerCommit)} tokens/commit`);
3176
+ return lines;
3177
+ },
3178
+ },
3179
+ },
3180
+ },
3154
3181
  scales: {
3155
- y: { type: 'linear', position: 'left', title: { display: true, text: 'Avg $/Commit' } },
3156
- y1: { type: 'linear', position: 'right', title: { display: true, text: 'Sessions' }, grid: { drawOnChartArea: false } },
3182
+ y: { type: 'linear', position: 'left', title: { display: true, text: 'Avg $/Commit' }, ticks: { callback: v => '$' + v.toFixed(1) } },
3183
+ y1: { type: 'linear', position: 'right', title: { display: true, text: 'Commits' }, grid: { drawOnChartArea: false } },
3157
3184
  },
3158
3185
  },
3159
3186
  });
@@ -3263,10 +3290,93 @@ window.toggleTimelineScale = function() {
3263
3290
  timelineChart.options.scales.y.type = scaleType;
3264
3291
  timelineChart.options.scales.y1.type = scaleType;
3265
3292
  timelineChart.update();
3266
- const btn = document.querySelector('.scale-toggle');
3293
+ const btn = document.querySelector('#chart-timeline').closest('.chart-card').querySelector('.scale-toggle');
3267
3294
  if (btn) btn.textContent = timelineLogScale ? 'Linear scale' : 'Log scale';
3268
3295
  };
3269
3296
 
3297
+ window.toggleTokenBurnPercent = function() {
3298
+ if (!tokenBurnChart) return;
3299
+ tokenBurnPercentMode = !tokenBurnPercentMode;
3300
+ const dailyTokens = DATA.daily;
3301
+ if (tokenBurnPercentMode) {
3302
+ // Switch to split line view — each token type gets its own line
3303
+ tokenBurnChart.data.datasets[0].type = 'line';
3304
+ tokenBurnChart.data.datasets[0].borderColor = 'rgba(59, 130, 246, 1)';
3305
+ tokenBurnChart.data.datasets[0].backgroundColor = 'rgba(59, 130, 246, 0.1)';
3306
+ tokenBurnChart.data.datasets[0].fill = true;
3307
+ tokenBurnChart.data.datasets[0].stack = undefined;
3308
+ tokenBurnChart.data.datasets[0].borderWidth = 2;
3309
+ tokenBurnChart.data.datasets[0].pointRadius = 2;
3310
+ tokenBurnChart.data.datasets[0].tension = 0.3;
3311
+
3312
+ tokenBurnChart.data.datasets[1].type = 'line';
3313
+ tokenBurnChart.data.datasets[1].borderColor = 'rgba(168, 85, 247, 1)';
3314
+ tokenBurnChart.data.datasets[1].backgroundColor = 'rgba(168, 85, 247, 0.1)';
3315
+ tokenBurnChart.data.datasets[1].fill = true;
3316
+ tokenBurnChart.data.datasets[1].stack = undefined;
3317
+ tokenBurnChart.data.datasets[1].borderWidth = 2;
3318
+ tokenBurnChart.data.datasets[1].pointRadius = 2;
3319
+ tokenBurnChart.data.datasets[1].tension = 0.3;
3320
+
3321
+ tokenBurnChart.data.datasets[2].type = 'line';
3322
+ tokenBurnChart.data.datasets[2].borderColor = 'rgba(6, 182, 212, 1)';
3323
+ tokenBurnChart.data.datasets[2].backgroundColor = 'rgba(6, 182, 212, 0.1)';
3324
+ tokenBurnChart.data.datasets[2].fill = true;
3325
+ tokenBurnChart.data.datasets[2].stack = undefined;
3326
+ tokenBurnChart.data.datasets[2].borderWidth = 2;
3327
+ tokenBurnChart.data.datasets[2].pointRadius = 2;
3328
+ tokenBurnChart.data.datasets[2].tension = 0.3;
3329
+
3330
+ tokenBurnChart.data.datasets[3].hidden = true;
3331
+ tokenBurnChart.options.scales.x.stacked = false;
3332
+ tokenBurnChart.options.scales.y.stacked = false;
3333
+ tokenBurnChart.options.scales.y.type = 'logarithmic';
3334
+ tokenBurnChart.options.scales.y.title.text = 'Tokens / Day (log)';
3335
+ tokenBurnChart.options.scales.y1.display = false;
3336
+ } else {
3337
+ // Restore stacked bar view
3338
+ tokenBurnChart.data.datasets[0].type = 'bar';
3339
+ tokenBurnChart.data.datasets[0].backgroundColor = 'rgba(59, 130, 246, 0.8)';
3340
+ tokenBurnChart.data.datasets[0].stack = 'tokens';
3341
+ tokenBurnChart.data.datasets[0].borderRadius = 2;
3342
+ delete tokenBurnChart.data.datasets[0].borderColor;
3343
+ delete tokenBurnChart.data.datasets[0].fill;
3344
+ delete tokenBurnChart.data.datasets[0].borderWidth;
3345
+ delete tokenBurnChart.data.datasets[0].pointRadius;
3346
+ delete tokenBurnChart.data.datasets[0].tension;
3347
+
3348
+ tokenBurnChart.data.datasets[1].type = 'bar';
3349
+ tokenBurnChart.data.datasets[1].backgroundColor = 'rgba(168, 85, 247, 0.8)';
3350
+ tokenBurnChart.data.datasets[1].stack = 'tokens';
3351
+ tokenBurnChart.data.datasets[1].borderRadius = 2;
3352
+ delete tokenBurnChart.data.datasets[1].borderColor;
3353
+ delete tokenBurnChart.data.datasets[1].fill;
3354
+ delete tokenBurnChart.data.datasets[1].borderWidth;
3355
+ delete tokenBurnChart.data.datasets[1].pointRadius;
3356
+ delete tokenBurnChart.data.datasets[1].tension;
3357
+
3358
+ tokenBurnChart.data.datasets[2].type = 'bar';
3359
+ tokenBurnChart.data.datasets[2].backgroundColor = 'rgba(6, 182, 212, 0.6)';
3360
+ tokenBurnChart.data.datasets[2].stack = 'tokens';
3361
+ tokenBurnChart.data.datasets[2].borderRadius = 2;
3362
+ delete tokenBurnChart.data.datasets[2].borderColor;
3363
+ delete tokenBurnChart.data.datasets[2].fill;
3364
+ delete tokenBurnChart.data.datasets[2].borderWidth;
3365
+ delete tokenBurnChart.data.datasets[2].pointRadius;
3366
+ delete tokenBurnChart.data.datasets[2].tension;
3367
+
3368
+ tokenBurnChart.data.datasets[3].hidden = false;
3369
+ tokenBurnChart.options.scales.x.stacked = true;
3370
+ tokenBurnChart.options.scales.y.stacked = true;
3371
+ tokenBurnChart.options.scales.y.type = 'linear';
3372
+ tokenBurnChart.options.scales.y.title.text = 'Tokens / Day';
3373
+ tokenBurnChart.options.scales.y1.display = true;
3374
+ }
3375
+ tokenBurnChart.update();
3376
+ const btn = document.querySelector('#chart-token-burn').closest('.chart-card').querySelector('.scale-toggle');
3377
+ if (btn) btn.textContent = tokenBurnPercentMode ? 'Stacked' : 'Split view';
3378
+ };
3379
+
3270
3380
  /* ── Share Report Card ─────────────────────────── */
3271
3381
 
3272
3382
  const GRADE_HEX = { A: '#22d3a8', B: '#3b82f6', C: '#f59e0b', D: '#f0883e', F: '#ef4444' };
package/src/index.js CHANGED
@@ -15,6 +15,20 @@ const { version: VERSION } = JSON.parse(
15
15
  readFileSync(new URL('../package.json', import.meta.url), 'utf8')
16
16
  );
17
17
 
18
+ // ── pretty CLI output helpers ──
19
+ const c = {
20
+ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
21
+ green: '\x1b[32m', yellow: '\x1b[33m', cyan: '\x1b[36m', red: '\x1b[31m',
22
+ };
23
+ const icon = {
24
+ ok: `${c.green}✔${c.reset}`,
25
+ dot: `${c.cyan}◆${c.reset}`,
26
+ arrow: `${c.cyan}▸${c.reset}`,
27
+ warn: `${c.yellow}⚠${c.reset}`,
28
+ err: `${c.red}✖${c.reset}`,
29
+ };
30
+ const fmt = (ms) => ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
31
+
18
32
  async function buildPayload(claudeDir, days, project, forceRefresh = false) {
19
33
  // Step 1: Parse sessions (with caching)
20
34
  let sessions;
@@ -23,7 +37,7 @@ async function buildPayload(claudeDir, days, project, forceRefresh = false) {
23
37
 
24
38
  if (forceRefresh) {
25
39
  deleteCache();
26
- console.log('Cache cleared, performing full parse...');
40
+ console.log(` ${icon.arrow} Cache cleared, performing full parse...`);
27
41
  }
28
42
 
29
43
  const cached = forceRefresh ? null : loadCache();
@@ -38,18 +52,18 @@ async function buildPayload(claudeDir, days, project, forceRefresh = false) {
38
52
  if (newCount === 0 && modifiedCount === 0 && deletedCount === 0) {
39
53
  sessions = cached.sessions;
40
54
  fileIndex = cached.fileIndex;
41
- console.log(`Parsing sessions... ${cached.sessions.length} cached (${Date.now() - startParse}ms)`);
55
+ console.log(` ${icon.ok} Parsing sessions ${c.dim}── ${cached.sessions.length} cached (${fmt(Date.now() - startParse)})${c.reset}`);
42
56
  } else {
43
57
  const { sessions: freshSessions, fileIndex: freshIndex } = await parseAllProjects(claudeDir, days, project);
44
58
  sessions = freshSessions;
45
59
  fileIndex = freshIndex;
46
- console.log(`Parsing sessions... ${newCount} new, ${modifiedCount} updated, ${Math.max(0, cachedCount)} cached (${Date.now() - startParse}ms)`);
60
+ console.log(` ${icon.ok} Parsing sessions ${c.dim}── ${newCount} new, ${modifiedCount} updated, ${Math.max(0, cachedCount)} cached (${fmt(Date.now() - startParse)})${c.reset}`);
47
61
  }
48
62
  } else {
49
63
  const result = await parseAllProjects(claudeDir, days, project);
50
64
  sessions = result.sessions;
51
65
  fileIndex = result.fileIndex;
52
- console.log(`Parsing sessions... ${sessions.length} parsed (${Date.now() - startParse}ms)`);
66
+ console.log(` ${icon.ok} Parsing sessions ${c.dim}── ${sessions.length} parsed (${fmt(Date.now() - startParse)})${c.reset}`);
53
67
  }
54
68
 
55
69
  if (sessions.length === 0) {
@@ -63,11 +77,11 @@ async function buildPayload(claudeDir, days, project, forceRefresh = false) {
63
77
  for (const repoPath of repoPathsSet) {
64
78
  commitsByRepo[repoPath] = analyzeGitRepo(repoPath, days);
65
79
  }
66
- console.log(`Analyzing ${repoPathsSet.size} git repo(s)... done (${Date.now() - startGit}ms)`);
80
+ console.log(` ${icon.ok} Analyzing git repos ${c.dim}── ${repoPathsSet.size} repos (${fmt(Date.now() - startGit)})${c.reset}`);
67
81
 
68
82
  // Step 3: Correlate sessions with commits
69
83
  const { correlatedSessions, organicCommits } = correlateSessions(sessions, commitsByRepo);
70
- console.log('Correlating sessions with commits... done');
84
+ console.log(` ${icon.ok} Correlating sessions ${c.dim}── done${c.reset}`);
71
85
 
72
86
  // Step 4: Compute metrics
73
87
  const payload = computeMetrics(correlatedSessions, organicCommits, commitsByRepo, days);
@@ -100,11 +114,10 @@ async function main() {
100
114
 
101
115
  const invokedAs = path.basename(process.argv[1]);
102
116
  if (invokedAs.includes('claude-roi')) {
103
- console.log(`\x1b[33m⚠ claude-roi has been renamed to codelens-ai\x1b[0m`);
104
- console.log(` Switch to: \x1b[36mnpx codelens-ai\x1b[0m`);
105
- console.log('');
117
+ console.log(` ${icon.warn} ${c.yellow}claude-roi has been renamed to codelens-ai${c.reset}`);
118
+ console.log(` Switch to: ${c.cyan}npx codelens-ai${c.reset}\n`);
106
119
  }
107
- console.log(`\x1b[36mcodelens-ai\x1b[0m v${VERSION}`);
120
+ console.log(`${icon.dot} ${c.bold}${c.cyan}codelens-ai${c.reset} v${VERSION}\n`);
108
121
 
109
122
  const claudeDir = path.join(os.homedir(), '.claude', 'projects');
110
123
 
@@ -112,8 +125,8 @@ async function main() {
112
125
  if (payload) payload.meta.invokedAs = invokedAs.includes('claude-roi') ? 'claude-roi' : 'codelens-ai';
113
126
 
114
127
  if (!payload) {
115
- console.log('\x1b[33mNo Claude Code sessions found.\x1b[0m');
116
- console.log('Make sure you have used Claude Code and session files exist in ~/.claude/projects/');
128
+ console.log(` ${icon.warn} ${c.yellow}No Claude Code sessions found.${c.reset}`);
129
+ console.log(` Make sure you have used Claude Code and session files exist in ~/.claude/projects/`);
117
130
  process.exit(0);
118
131
  }
119
132
 
@@ -138,7 +151,7 @@ async function main() {
138
151
  console.log(` ${line}`);
139
152
  if (am.topVerificationCommands.length > 0) {
140
153
  const top3 = am.topVerificationCommands.slice(0, 3)
141
- .map(c => `${c.command} (${c.count})`).join(', ');
154
+ .map(cmd => `${cmd.command} (${cmd.count})`).join(', ');
142
155
  console.log(` Top Tests: ${top3}`);
143
156
  }
144
157
  console.log('');
@@ -150,7 +163,7 @@ async function main() {
150
163
  const app = createServer(payload, rebuild);
151
164
  const server = app.listen(port, () => {
152
165
  const url = `http://localhost:${port}`;
153
- console.log(`\x1b[32mDashboard:\x1b[0m ${url}`);
166
+ console.log(`\n ${icon.ok} ${c.green}Dashboard:${c.reset} ${c.bold}${url}${c.reset}`);
154
167
 
155
168
  if (opts.open !== false) {
156
169
  import('open').then(mod => mod.default(url)).catch(() => {
@@ -161,7 +174,7 @@ async function main() {
161
174
 
162
175
  server.on('error', (err) => {
163
176
  if (err.code === 'EADDRINUSE') {
164
- console.error(`\x1b[31mPort ${port} is already in use.\x1b[0m Try: codelens-ai --port ${port + 1}`);
177
+ console.error(` ${icon.err} ${c.red}Port ${port} is already in use.${c.reset} Try: codelens-ai --port ${port + 1}`);
165
178
  process.exit(1);
166
179
  }
167
180
  throw err;
@@ -169,6 +182,6 @@ async function main() {
169
182
  }
170
183
 
171
184
  main().catch(err => {
172
- console.error('\x1b[31mError:\x1b[0m', err.message);
185
+ console.error(` ${icon.err} ${c.red}Error:${c.reset}`, err.message);
173
186
  process.exit(1);
174
187
  });
package/src/server.js CHANGED
@@ -25,14 +25,14 @@ export function createServer(initialPayload, rebuildFn) {
25
25
  app.post('/api/refresh', async (req, res) => {
26
26
  if (!rebuildFn) return res.status(501).json({ error: 'Refresh not available' });
27
27
  try {
28
- console.log('\x1b[36m[refresh]\x1b[0m Re-parsing sessions and recomputing metrics...');
28
+ console.log(' \x1b[36m▸\x1b[0m \x1b[36m[refresh]\x1b[0m Re-parsing sessions and recomputing metrics...');
29
29
  const newPayload = await rebuildFn();
30
30
  if (!newPayload) return res.status(404).json({ error: 'No sessions found after refresh' });
31
31
  payload = newPayload;
32
- console.log('\x1b[32m[refresh]\x1b[0m Done');
32
+ console.log(' \x1b[32m✔\x1b[0m \x1b[32m[refresh]\x1b[0m Done');
33
33
  res.json({ ok: true });
34
34
  } catch (err) {
35
- console.error('\x1b[31m[refresh]\x1b[0m Error:', err.message);
35
+ console.error(' \x1b[31m✖\x1b[0m \x1b[31m[refresh]\x1b[0m Error:', err.message);
36
36
  res.status(500).json({ error: err.message });
37
37
  }
38
38
  });