Skip to content

Commit 05cd3f5

Browse files
author
Pawel
committed
feat: sub-tabs в Cost Analytics (Overview / Breakdown / History)
1 parent c378bc5 commit 05cd3f5

2 files changed

Lines changed: 131 additions & 23 deletions

File tree

src/frontend/analytics.js

Lines changed: 103 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@
33
var _analyticsHtmlCache = null;
44
var _analyticsCacheUrl = null;
55

6+
function switchAnalyticsTab(tab) {
7+
document.querySelectorAll('.atab-pane').forEach(function(el) {
8+
el.style.display = el.dataset.tab === tab ? 'block' : 'none';
9+
});
10+
document.querySelectorAll('.atab-btn').forEach(function(el) {
11+
el.classList.toggle('active', el.dataset.tab === tab);
12+
});
13+
localStorage.setItem('codedash-analytics-tab', tab);
14+
}
15+
616
async function renderAnalytics(container) {
717
// Check frontend cache first — show instantly if same filters
818
var url = '/api/analytics/cost';
@@ -13,6 +23,8 @@ async function renderAnalytics(container) {
1323

1424
if (_analyticsHtmlCache && _analyticsCacheUrl === url) {
1525
container.innerHTML = _analyticsHtmlCache;
26+
var activeTab = localStorage.getItem('codedash-analytics-tab') || 'overview';
27+
switchAnalyticsTab(activeTab);
1628
return;
1729
}
1830

@@ -28,6 +40,16 @@ async function renderAnalytics(container) {
2840
var html = '<div class="analytics-container">';
2941
html += '<h2 class="heatmap-title">Cost Analytics</h2>';
3042

43+
// ── Tab bar ────────────────────────────────────────────────
44+
html += '<div class="analytics-tabs">';
45+
html += '<button class="atab-btn" data-tab="overview" onclick="switchAnalyticsTab(\'overview\')">Overview</button>';
46+
html += '<button class="atab-btn" data-tab="breakdown" onclick="switchAnalyticsTab(\'breakdown\')">Breakdown</button>';
47+
html += '<button class="atab-btn" data-tab="history" onclick="switchAnalyticsTab(\'history\')">History</button>';
48+
html += '</div>';
49+
50+
// ══ TAB: Overview ══════════════════════════════════════════
51+
html += '<div class="atab-pane" data-tab="overview">';
52+
3153
// ── Summary cards ──────────────────────────────────────────
3254
html += '<div class="analytics-summary">';
3355
html += '<div class="analytics-card"><span class="analytics-val">$' + data.totalCost.toFixed(2) + '</span><span class="analytics-label">Total cost (API-equivalent)</span></div>';
@@ -83,6 +105,32 @@ async function renderAnalytics(container) {
83105
}
84106
}
85107

108+
// ── Cost by agent (overview) ───────────────────────────────
109+
var agentEntriesOv = Object.entries(data.byAgent || {}).filter(function(e) { return e[1].sessions > 0; });
110+
if (agentEntriesOv.length > 1) {
111+
agentEntriesOv.sort(function(a, b) { return b[1].cost - a[1].cost; });
112+
html += '<div class="chart-section"><h3>Cost by Agent</h3>';
113+
html += '<div class="hbar-chart">';
114+
var maxAgentCostOv = agentEntriesOv[0][1].cost || 1;
115+
agentEntriesOv.forEach(function(entry) {
116+
var name = entry[0]; var info = entry[1];
117+
var pct = maxAgentCostOv > 0 ? (info.cost / maxAgentCostOv * 100) : 0;
118+
var label = { 'claude': 'Claude Code', 'claude-ext': 'Claude Ext', 'codex': 'Codex', 'opencode': 'OpenCode', 'cursor': 'Cursor', 'kiro': 'Kiro' }[name] || name;
119+
var estMark = info.estimated ? ' <span style="font-size:10px;opacity:0.6">~est.</span>' : '';
120+
html += '<div class="hbar-row">';
121+
html += '<span class="hbar-name">' + label + estMark + '</span>';
122+
html += '<div class="hbar-track"><div class="hbar-fill" style="width:' + pct + '%"></div></div>';
123+
html += '<span class="hbar-val">$' + info.cost.toFixed(2) + ' <span style="font-size:10px;opacity:0.6">(' + info.sessions + ' sess.)</span></span>';
124+
html += '</div>';
125+
});
126+
html += '</div></div>';
127+
}
128+
129+
html += '</div>'; // end atab-pane overview
130+
131+
// ══ TAB: Breakdown ═════════════════════════════════════════
132+
html += '<div class="atab-pane" data-tab="breakdown">';
133+
86134
// ── Token breakdown ────────────────────────────────────────
87135
if (data.totalInputTokens !== undefined) {
88136
var totalTok = data.totalInputTokens + data.totalOutputTokens + data.totalCacheReadTokens + data.totalCacheCreateTokens;
@@ -98,9 +146,57 @@ async function renderAnalytics(container) {
98146
html += '<div class="token-type-card token-context"><span class="token-type-val">' + data.avgContextPct + '%</span><span class="token-type-label">Avg context used</span><span class="token-type-pct">of 200K</span></div>';
99147
}
100148
html += '</div>';
101-
html += '</div>';
149+
150+
// ── Cost attribution stacked bar ──────────────────────────
151+
// Uses Sonnet-baseline ratios projected onto actual totalCost.
152+
// Ratios are model-agnostic (Claude output/input is ~5:1 across all tiers).
153+
if (data.outputCostEst !== undefined && data.totalCost > 0) {
154+
var estTotal = data.inputCostEst + data.outputCostEst + data.cacheReadCostEst + data.cacheCreateCostEst;
155+
var sharePct = function(v) { return estTotal > 0 ? (v / estTotal * 100) : 0; };
156+
var actualOf = function(v) { return (sharePct(v) / 100 * data.totalCost); };
157+
158+
var outPct = sharePct(data.outputCostEst).toFixed(1);
159+
var inPct = sharePct(data.inputCostEst).toFixed(1);
160+
var cwPct = sharePct(data.cacheCreateCostEst).toFixed(1);
161+
var crPct = sharePct(data.cacheReadCostEst).toFixed(1);
162+
163+
html += '<div class="cost-attr-section">';
164+
html += '<div class="cost-attr-title">Where your money goes</div>';
165+
html += '<div class="cost-attr-bar">';
166+
if (parseFloat(outPct) > 0) html += '<div class="cost-attr-seg seg-output" style="width:' + outPct + '%" title="Output tokens: ~' + outPct + '% of cost"></div>';
167+
if (parseFloat(inPct) > 0) html += '<div class="cost-attr-seg seg-input" style="width:' + inPct + '%" title="Input tokens: ~' + inPct + '% of cost"></div>';
168+
if (parseFloat(cwPct) > 0) html += '<div class="cost-attr-seg seg-cw" style="width:' + cwPct + '%" title="Cache write: ~' + cwPct + '% of cost"></div>';
169+
if (parseFloat(crPct) > 0) html += '<div class="cost-attr-seg seg-cr" style="width:' + crPct + '%" title="Cache read: ~' + crPct + '% of cost"></div>';
170+
html += '</div>';
171+
html += '<div class="cost-attr-legend">';
172+
html += '<span class="cost-attr-item"><span class="cost-attr-dot seg-output"></span>Output ~' + outPct + '% (~$' + actualOf(data.outputCostEst).toFixed(2) + ')</span>';
173+
html += '<span class="cost-attr-item"><span class="cost-attr-dot seg-input"></span>Input ~' + inPct + '% (~$' + actualOf(data.inputCostEst).toFixed(2) + ')</span>';
174+
if (parseFloat(cwPct) > 0) html += '<span class="cost-attr-item"><span class="cost-attr-dot seg-cw"></span>Cache write ~' + cwPct + '%</span>';
175+
if (parseFloat(crPct) > 0) html += '<span class="cost-attr-item"><span class="cost-attr-dot seg-cr"></span>Cache read ~' + crPct + '%</span>';
176+
html += '</div>';
177+
178+
if (data.cacheHitRate > 0 || data.cacheSavings > 0) {
179+
html += '<div class="cache-metrics">';
180+
if (data.cacheHitRate > 0) {
181+
var hitColor = data.cacheHitRate >= 60 ? 'var(--accent-green)' : data.cacheHitRate >= 30 ? '#f59e0b' : 'var(--text-muted)';
182+
html += '<span class="cache-metric" style="color:' + hitColor + '">Cache hit rate: <b>' + data.cacheHitRate + '%</b></span>';
183+
}
184+
if (data.cacheSavings > 0.001) {
185+
html += '<span class="cache-metric" style="color:var(--accent-green)">Cache saved ~<b>$' + data.cacheSavings.toFixed(0) + '</b> vs no-cache</span>';
186+
}
187+
html += '</div>';
188+
}
189+
html += '</div>';
190+
}
191+
192+
html += '</div>'; // chart-section
102193
}
103194

195+
html += '</div>'; // end atab-pane breakdown
196+
197+
// ══ TAB: History ═══════════════════════════════════════════
198+
html += '<div class="atab-pane" data-tab="history">';
199+
104200
// ── Subscription vs API ────────────────────────────────────
105201
var sub = getSubscriptionConfig();
106202
var subEntries = (sub && sub.entries) || [];
@@ -212,31 +308,15 @@ async function renderAnalytics(container) {
212308
html += '</div></div>';
213309
}
214310

215-
// ── Cost by agent ──────────────────────────────────────────
216-
var agentEntries = Object.entries(data.byAgent || {}).filter(function(e) { return e[1].sessions > 0; });
217-
if (agentEntries.length > 1) {
218-
agentEntries.sort(function(a, b) { return b[1].cost - a[1].cost; });
219-
html += '<div class="chart-section"><h3>Cost by Agent</h3>';
220-
html += '<div class="hbar-chart">';
221-
var maxAgentCost = agentEntries[0][1].cost || 1;
222-
agentEntries.forEach(function(entry) {
223-
var name = entry[0]; var info = entry[1];
224-
var pct = maxAgentCost > 0 ? (info.cost / maxAgentCost * 100) : 0;
225-
var label = { 'claude': 'Claude Code', 'claude-ext': 'Claude Ext', 'codex': 'Codex', 'opencode': 'OpenCode', 'cursor': 'Cursor', 'kiro': 'Kiro' }[name] || name;
226-
var estMark = info.estimated ? ' <span style="font-size:10px;opacity:0.6">~est.</span>' : '';
227-
html += '<div class="hbar-row">';
228-
html += '<span class="hbar-name">' + label + estMark + '</span>';
229-
html += '<div class="hbar-track"><div class="hbar-fill" style="width:' + pct + '%"></div></div>';
230-
html += '<span class="hbar-val">$' + info.cost.toFixed(2) + ' <span style="font-size:10px;opacity:0.6">(' + info.sessions + ' sess.)</span></span>';
231-
html += '</div>';
232-
});
233-
html += '</div></div>';
234-
}
235-
236-
html += '</div>';
311+
html += '</div>'; // end atab-pane history
312+
html += '</div>'; // analytics-container
237313
container.innerHTML = html;
238314
_analyticsHtmlCache = html;
239315
_analyticsCacheUrl = url;
316+
317+
// Activate the stored (or default) tab
318+
var activeTab = localStorage.getItem('codedash-analytics-tab') || 'overview';
319+
switchAnalyticsTab(activeTab);
240320
} catch (e) {
241321
container.innerHTML = '<div class="empty-state">Failed to load analytics.</div>';
242322
}

src/frontend/styles.css

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2486,6 +2486,34 @@ body {
24862486

24872487
.analytics-container { padding: 20px; }
24882488

2489+
/* ── Analytics sub-tabs ─────────────────────────────────────── */
2490+
.analytics-tabs {
2491+
display: flex;
2492+
gap: 4px;
2493+
margin-bottom: 20px;
2494+
border-bottom: 1px solid var(--border);
2495+
padding-bottom: 0;
2496+
}
2497+
.atab-btn {
2498+
background: none;
2499+
border: none;
2500+
border-bottom: 2px solid transparent;
2501+
padding: 8px 16px;
2502+
margin-bottom: -1px;
2503+
font-size: 13px;
2504+
font-weight: 500;
2505+
color: var(--text-secondary);
2506+
cursor: pointer;
2507+
transition: color 0.15s, border-color 0.15s;
2508+
}
2509+
.atab-btn:hover { color: var(--text-primary); }
2510+
.atab-btn.active {
2511+
color: var(--accent-blue);
2512+
border-bottom-color: var(--accent-blue);
2513+
}
2514+
.atab-pane { display: none; }
2515+
.atab-pane[data-tab="overview"] { display: block; } /* default visible until JS runs */
2516+
24892517
.analytics-summary {
24902518
display: grid;
24912519
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));

0 commit comments

Comments
 (0)