33var _analyticsHtmlCache = null ;
44var _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+
616async 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 }
0 commit comments