diff --git a/src/data.js b/src/data.js
index 83b08af..1b6d03c 100644
--- a/src/data.js
+++ b/src/data.js
@@ -1300,6 +1300,8 @@ function getSessionReplay(sessionId, project) {
};
}
+const CONTEXT_WINDOW = 200_000; // Claude's max context window (tokens)
+
// ── Pricing per model (per token, April 2026) ─────────────
const MODEL_PRICING = {
@@ -1329,11 +1331,15 @@ function getModelPricing(model) {
function computeSessionCost(sessionId, project) {
const found = findSessionFile(sessionId, project);
- if (!found) return { cost: 0, inputTokens: 0, outputTokens: 0, model: '' };
+ if (!found) return { cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, contextPctSum: 0, contextTurnCount: 0, model: '' };
let totalCost = 0;
let totalInput = 0;
let totalOutput = 0;
+ let totalCacheRead = 0;
+ let totalCacheCreate = 0;
+ let contextPctSum = 0;
+ let contextTurnCount = 0;
let model = '';
try {
@@ -1353,12 +1359,21 @@ function computeSessionCost(sessionId, project) {
const cacheRead = u.cache_read_input_tokens || 0;
const out = u.output_tokens || 0;
- totalInput += inp + cacheCreate + cacheRead;
+ totalInput += inp;
totalOutput += out;
+ totalCacheRead += cacheRead;
+ totalCacheCreate += cacheCreate;
totalCost += inp * pricing.input
+ cacheCreate * pricing.cache_create
+ cacheRead * pricing.cache_read
+ out * pricing.output;
+
+ // Track per-turn context window usage (average, not peak)
+ const contextThisTurn = inp + cacheCreate + cacheRead;
+ if (contextThisTurn > 0) {
+ contextPctSum += (contextThisTurn / CONTEXT_WINDOW) * 100;
+ contextTurnCount++;
+ }
}
// Codex: estimate from file size (no token usage in session files)
} catch {}
@@ -1377,7 +1392,7 @@ function computeSessionCost(sessionId, project) {
} catch {}
}
- return { cost: totalCost, inputTokens: totalInput, outputTokens: totalOutput, model };
+ return { cost: totalCost, inputTokens: totalInput, outputTokens: totalOutput, cacheReadTokens: totalCacheRead, cacheCreateTokens: totalCacheCreate, contextPctSum, contextTurnCount, model };
}
// ── Cost analytics ────────────────────────────────────────
@@ -1386,20 +1401,59 @@ function getCostAnalytics(sessions) {
const byDay = {};
const byProject = {};
const byWeek = {};
+ const byAgent = {};
let totalCost = 0;
let totalTokens = 0;
+ let totalInputTokens = 0;
+ let totalOutputTokens = 0;
+ let totalCacheReadTokens = 0;
+ let totalCacheCreateTokens = 0;
+ let globalContextPctSum = 0;
+ let globalContextTurnCount = 0;
+ let firstDate = null;
+ let lastDate = null;
+ let sessionsWithData = 0;
+ const agentNoCostData = {};
+ for (const s of sessions) {
+ if (!byAgent[s.tool]) byAgent[s.tool] = { cost: 0, sessions: 0, tokens: 0, estimated: false };
+ }
const sessionCosts = [];
for (const s of sessions) {
const costData = computeSessionCost(s.id, s.project);
const cost = costData.cost;
- const tokens = costData.inputTokens + costData.outputTokens;
- if (cost === 0 && tokens === 0) continue;
+ const tokens = costData.inputTokens + costData.outputTokens + costData.cacheReadTokens + costData.cacheCreateTokens;
+ if (cost === 0 && tokens === 0) {
+ if (!agentNoCostData[s.tool]) agentNoCostData[s.tool] = 0;
+ agentNoCostData[s.tool]++;
+ continue;
+ }
+ sessionsWithData++;
totalCost += cost;
totalTokens += tokens;
-
- // By day
+ totalInputTokens += costData.inputTokens;
+ totalOutputTokens += costData.outputTokens;
+ totalCacheReadTokens += costData.cacheReadTokens;
+ totalCacheCreateTokens += costData.cacheCreateTokens;
+
+ // Per-agent breakdown
+ const agent = s.tool || 'unknown';
+ if (!byAgent[agent]) byAgent[agent] = { cost: 0, sessions: 0, tokens: 0, estimated: false };
+ byAgent[agent].cost += cost;
+ byAgent[agent].sessions++;
+ byAgent[agent].tokens += tokens;
+ if (agent === 'codex') byAgent[agent].estimated = true;
+
+ // Context % across all turns
+ globalContextPctSum += costData.contextPctSum;
+ globalContextTurnCount += costData.contextTurnCount;
+
+ // Date range
const day = s.date || 'unknown';
+ if (s.date) {
+ if (!firstDate || s.date < firstDate) firstDate = s.date;
+ if (!lastDate || s.date > lastDate) lastDate = s.date;
+ }
if (!byDay[day]) byDay[day] = { cost: 0, sessions: 0, tokens: 0 };
byDay[day].cost += cost;
byDay[day].sessions++;
@@ -1429,14 +1483,29 @@ function getCostAnalytics(sessions) {
// Sort top sessions by cost
sessionCosts.sort((a, b) => b.cost - a.cost);
+ const days = firstDate && lastDate
+ ? Math.max(1, Math.round((new Date(lastDate) - new Date(firstDate)) / 86400000) + 1)
+ : 1;
+
return {
totalCost,
totalTokens,
- totalSessions: sessions.length,
+ totalInputTokens,
+ totalOutputTokens,
+ totalCacheReadTokens,
+ totalCacheCreateTokens,
+ avgContextPct: globalContextTurnCount > 0 ? Math.round(globalContextPctSum / globalContextTurnCount) : 0,
+ dailyRate: totalCost / days,
+ firstDate,
+ lastDate,
+ days,
+ totalSessions: sessionsWithData,
byDay,
byWeek,
byProject,
topSessions: sessionCosts.slice(0, 10),
+ byAgent,
+ agentNoCostData,
};
}
diff --git a/src/frontend/app.js b/src/frontend/app.js
index 3519af5..030bead 100644
--- a/src/frontend/app.js
+++ b/src/frontend/app.js
@@ -100,6 +100,34 @@ function estimateCost(fileSize) {
return tokens * 0.3 * (3.0 / 1e6) + tokens * 0.7 * (15.0 / 1e6);
}
+// ── Subscription config helpers ──────────────────────────────────
+function getSubscriptionConfig() {
+ var raw = JSON.parse(localStorage.getItem('codedash-subscription') || 'null');
+ if (!raw) return { entries: [] };
+ // Migrate old single-entry format {plan, paid} → new multi-period {entries: [...]}
+ if (!raw.entries) return { entries: [{ plan: raw.plan || 'Subscription', paid: raw.paid || 0, from: '' }] };
+ return raw;
+}
+function saveSubscriptionConfig(cfg) { localStorage.setItem('codedash-subscription', JSON.stringify(cfg)); }
+function subTotalPaid(entries) { return entries.reduce(function(s,e){return s+(parseFloat(e.paid)||0);},0); }
+function addSubEntry() {
+ var plan = (document.getElementById('sub-new-plan').value || '').trim();
+ var paid = parseFloat(document.getElementById('sub-new-paid').value) || 0;
+ var from = (document.getElementById('sub-new-from').value || '').trim();
+ if (!paid) return;
+ var cfg = getSubscriptionConfig();
+ cfg.entries.push({ plan: plan || 'Subscription', paid: paid, from: from });
+ cfg.entries.sort(function(a,b){return (a.from||'').localeCompare(b.from||'');});
+ saveSubscriptionConfig(cfg);
+ render();
+}
+function removeSubEntry(idx) {
+ var cfg = getSubscriptionConfig();
+ cfg.entries.splice(idx, 1);
+ saveSubscriptionConfig(cfg);
+ render();
+}
+
async function loadRealCost(sessionId, project) {
try {
var resp = await fetch('/api/cost/' + sessionId + '?project=' + encodeURIComponent(project));
@@ -1277,10 +1305,13 @@ async function openDetail(s) {
var row = document.getElementById('detail-real-cost');
if (row) {
row.style.display = '';
+ var cacheStr = '';
+ if ((costData.cacheReadTokens || 0) + (costData.cacheCreateTokens || 0) > 0)
+ cacheStr = ' / ' + formatTokens((costData.cacheReadTokens||0) + (costData.cacheCreateTokens||0)) + ' cache';
row.querySelector('span:last-child').innerHTML =
'$' + costData.cost.toFixed(2) + ' ' +
' ' +
- formatTokens(costData.inputTokens) + ' in / ' + formatTokens(costData.outputTokens) + ' out' +
+ formatTokens(costData.inputTokens) + ' in / ' + formatTokens(costData.outputTokens) + ' out' + cacheStr +
(costData.model ? ' (' + costData.model + ')' : '') + ' ';
}
// Update estimated badge to show it was estimated
@@ -1826,17 +1857,111 @@ async function renderAnalytics(container) {
var html = '
';
html += '
Cost Analytics ';
- // Summary cards
+ // ── Summary cards ──────────────────────────────────────────
html += '
';
- html += '
~$' + data.totalCost.toFixed(2) + ' Total estimated cost
';
+ html += '
$' + data.totalCost.toFixed(2) + ' Total cost (API-equivalent)
';
html += '
' + formatTokens(data.totalTokens) + ' Total tokens
';
+ html += '
$' + (data.dailyRate || 0).toFixed(2) + ' Avg per day (' + (data.days || 1) + ' days)
';
html += '
' + data.totalSessions + ' Sessions
';
- html += '
~$' + (data.totalCost / Math.max(data.totalSessions, 1)).toFixed(2) + ' Avg per session
';
html += '
';
- // Cost by day chart (bar chart)
- var days = Object.keys(data.byDay).sort();
- var last30 = days.slice(-30);
+ // ── Data coverage note ────────────────────────────────────
+ if (data.byAgent || data.agentNoCostData) {
+ var coverageparts = [];
+ var byAgent = data.byAgent || {};
+ var noCost = data.agentNoCostData || {};
+ if (byAgent['claude'] && byAgent['claude'].sessions > 0)
+ coverageparts.push('
Claude Code \u2713 ');
+ if (byAgent['claude-ext'] && byAgent['claude-ext'].sessions > 0)
+ coverageparts.push('
Claude Extension \u2713 ');
+ if (byAgent['codex'] && byAgent['codex'].sessions > 0)
+ coverageparts.push('
Codex ~est. ');
+ if (byAgent['opencode'] && byAgent['opencode'].sessions > 0)
+ coverageparts.push(byAgent['opencode'].estimated
+ ? '
OpenCode ~est. '
+ : '
OpenCode \u2713 ');
+ ['cursor', 'kiro'].forEach(function(a) {
+ if (noCost[a] > 0)
+ coverageparts.push('
' + a + ' \u2717 (no token data) ');
+ });
+ if (noCost['opencode'] > 0 && !(byAgent['opencode'] && byAgent['opencode'].sessions > 0))
+ coverageparts.push('
opencode \u2717 (no token data) ');
+ if (coverageparts.length > 0) {
+ html += '
Cost data: ' + coverageparts.join(' \u00b7 ') + '
';
+ }
+ }
+
+ // ── Token breakdown ────────────────────────────────────────
+ if (data.totalInputTokens !== undefined) {
+ var totalTok = data.totalInputTokens + data.totalOutputTokens + data.totalCacheReadTokens + data.totalCacheCreateTokens;
+ var pctOf = function(n) { return totalTok > 0 ? Math.round(n / totalTok * 100) : 0; };
+ html += '
';
+ html += '
Token Breakdown ';
+ html += '
';
+ html += '
' + formatTokens(data.totalInputTokens) + ' Input ' + pctOf(data.totalInputTokens) + '%
';
+ html += '
' + formatTokens(data.totalOutputTokens) + ' Output ' + pctOf(data.totalOutputTokens) + '%
';
+ html += '
' + formatTokens(data.totalCacheReadTokens) + ' Cache read ' + pctOf(data.totalCacheReadTokens) + '%
';
+ html += '
' + formatTokens(data.totalCacheCreateTokens) + ' Cache write ' + pctOf(data.totalCacheCreateTokens) + '%
';
+ if (data.avgContextPct > 0) {
+ html += '
' + data.avgContextPct + '% Avg context used of 200K
';
+ }
+ html += '
';
+ html += '
';
+ }
+
+ // ── Subscription vs API ────────────────────────────────────
+ var sub = getSubscriptionConfig();
+ var subEntries = (sub && sub.entries) || [];
+ var totalPaid = subTotalPaid(subEntries);
+ html += '
';
+ html += '
Subscription vs API ';
+
+ if (totalPaid > 0) {
+ var savings = data.totalCost - totalPaid;
+ var multiplier = data.totalCost / totalPaid;
+ var savingsPositive = savings > 0;
+ var breakdown = subEntries.map(function(e) {
+ return escHtml(e.plan || 'Sub') + ' $' + parseFloat(e.paid).toFixed(0);
+ }).join(' + ');
+ html += '
';
+ html += '
$' + totalPaid.toFixed(2) + ' Paid (' + breakdown + ')
';
+ html += '
$' + data.totalCost.toFixed(2) + ' Would cost at API rates
';
+ html += '
' + (savingsPositive ? '+' : '') + '$' + Math.abs(savings).toFixed(2) + ' ' + (savingsPositive ? 'Saved (' + multiplier.toFixed(1) + '\u00d7 ROI)' : 'API would be cheaper') + '
';
+ html += '
';
+ var barPct = Math.min(100, data.totalCost > 0 ? (totalPaid / data.totalCost * 100) : 100);
+ html += '
';
+ html += '
';
+ html += '
';
+ } else {
+ html += '
Add your subscription periods below to see how much you\'re saving vs API rates.
';
+ }
+
+ // Period list
+ html += '
';
+ if (subEntries.length > 0) {
+ subEntries.forEach(function(e, i) {
+ html += '
';
+ html += '' + escHtml(e.plan || '\u2014') + ' ';
+ html += '$' + parseFloat(e.paid || 0).toFixed(2) + ' ';
+ html += '' + (e.from ? 'from ' + e.from : 'no date') + ' ';
+ html += '\u00d7 ';
+ html += '
';
+ });
+ }
+ html += '
';
+
+ // Add form
+ html += '
';
+ html += ' ';
+ html += ' ';
+ html += ' ';
+ html += '+ Add period ';
+ html += '
';
+ html += '
';
+
+ // ── Daily cost chart ───────────────────────────────────────
+ var dayKeys = Object.keys(data.byDay).sort();
+ var last30 = dayKeys.slice(-30);
if (last30.length > 0) {
var maxCost = Math.max.apply(null, last30.map(function(d) { return data.byDay[d].cost; }));
html += '
Daily Cost (last 30 days) ';
@@ -1845,7 +1970,7 @@ async function renderAnalytics(container) {
var c = data.byDay[d];
var pct = maxCost > 0 ? (c.cost / maxCost * 100) : 0;
var label = d.slice(5); // MM-DD
- html += '
';
+ html += '
';
html += '
';
html += '
' + label + '
';
html += '
';
@@ -1853,7 +1978,7 @@ async function renderAnalytics(container) {
html += '
';
}
- // Cost by project (horizontal bars)
+ // ── Cost by project ────────────────────────────────────────
var projects = Object.entries(data.byProject).sort(function(a, b) { return b[1].cost - a[1].cost; });
var topProjects = projects.slice(0, 10);
if (topProjects.length > 0) {
@@ -1867,19 +1992,19 @@ async function renderAnalytics(container) {
html += '
';
html += '
' + escHtml(name) + ' ';
html += '
';
- html += '
~$' + info.cost.toFixed(2) + ' ';
+ html += '
$' + info.cost.toFixed(2) + ' ';
html += '
';
});
html += '
';
}
- // Top expensive sessions
+ // ── Top expensive sessions ─────────────────────────────────
if (data.topSessions && data.topSessions.length > 0) {
html += 'Most Expensive Sessions ';
html += '
';
data.topSessions.forEach(function(s) {
html += '
';
- html += '~$' + s.cost.toFixed(2) + ' ';
+ html += '$' + s.cost.toFixed(2) + ' ';
html += '' + escHtml(s.project) + ' ';
html += '' + (s.date || '') + ' ';
html += '' + s.id.slice(0, 8) + ' ';
@@ -1888,6 +2013,27 @@ async function renderAnalytics(container) {
html += '
';
}
+ // ── Cost by agent ──────────────────────────────────────────
+ var agentEntries = Object.entries(data.byAgent || {}).filter(function(e) { return e[1].sessions > 0; });
+ if (agentEntries.length > 1) {
+ agentEntries.sort(function(a, b) { return b[1].cost - a[1].cost; });
+ html += '
Cost by Agent ';
+ html += '
';
+ var maxAgentCost = agentEntries[0][1].cost || 1;
+ agentEntries.forEach(function(entry) {
+ var name = entry[0]; var info = entry[1];
+ var pct = maxAgentCost > 0 ? (info.cost / maxAgentCost * 100) : 0;
+ var label = { 'claude': 'Claude Code', 'claude-ext': 'Claude Ext', 'codex': 'Codex', 'opencode': 'OpenCode', 'cursor': 'Cursor', 'kiro': 'Kiro' }[name] || name;
+ var estMark = info.estimated ? '
~est. ' : '';
+ html += '
';
+ html += '
' + label + estMark + ' ';
+ html += '
';
+ html += '
$' + info.cost.toFixed(2) + ' (' + info.sessions + ' sess.) ';
+ html += '
';
+ });
+ html += '
';
+ }
+
html += '
';
container.innerHTML = html;
} catch (e) {
diff --git a/src/frontend/styles.css b/src/frontend/styles.css
index 3758fcb..f2df9e1 100644
--- a/src/frontend/styles.css
+++ b/src/frontend/styles.css
@@ -2073,6 +2073,155 @@ body {
.top-session-date { color: var(--text-muted); font-size: 12px; }
.top-session-id { font-family: monospace; font-size: 11px; color: var(--text-muted); }
+/* ── Data coverage indicators ──────────────────────────────── */
+
+.analytics-coverage {
+ font-size: 12px;
+ color: var(--text-muted);
+ margin: -8px 0 16px;
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ align-items: center;
+}
+
+.coverage-ok { color: var(--accent-green); }
+.coverage-est { color: var(--accent-orange, #f59e0b); }
+.coverage-none { color: var(--text-muted); opacity: 0.6; }
+
+/* ── Token breakdown grid ─────────────────────────────────── */
+
+.token-breakdown-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+ gap: 10px;
+}
+
+.token-type-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 12px;
+ text-align: center;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.token-type-val { font-size: 18px; font-weight: 700; color: var(--text); }
+.token-type-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; }
+.token-type-pct { font-size: 12px; color: var(--text-muted); }
+
+.token-cache-read { border-color: rgba(96, 165, 250, 0.3); }
+.token-cache-create { border-color: rgba(251, 191, 36, 0.3); }
+.token-context { border-color: rgba(168, 85, 247, 0.3); }
+
+/* ── Subscription vs API ──────────────────────────────────── */
+
+.subscription-section { margin-top: 8px; }
+
+.sub-comparison {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ gap: 10px;
+ margin-bottom: 12px;
+}
+
+.sub-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 14px;
+ text-align: center;
+}
+
+.sub-val { font-size: 20px; font-weight: 700; display: block; }
+.sub-label { font-size: 11px; color: var(--text-muted); display: block; margin-top: 4px; }
+
+.sub-paid .sub-val { color: var(--text); }
+.sub-api .sub-val { color: var(--accent-blue, #60a5fa); }
+.sub-savings .sub-val { color: var(--accent-green); }
+.sub-loss .sub-val { color: var(--accent-red, #f87171); }
+
+.sub-bar-track {
+ height: 8px;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ margin-bottom: 14px;
+ overflow: hidden;
+}
+
+.sub-bar-fill {
+ height: 100%;
+ background: var(--accent-green);
+ border-radius: 4px;
+ transition: width 0.3s;
+}
+
+.sub-hint {
+ color: var(--text-muted);
+ font-size: 13px;
+ margin: 8px 0;
+}
+
+.sub-entries { margin-bottom: 10px; }
+
+.sub-entry-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 6px 0;
+ border-bottom: 1px solid var(--border);
+ font-size: 13px;
+}
+
+.sub-entry-plan { font-weight: 600; min-width: 80px; }
+.sub-entry-paid { color: var(--accent-green); min-width: 70px; }
+.sub-entry-from { color: var(--text-muted); flex: 1; }
+.sub-entry-remove {
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ font-size: 16px;
+ padding: 2px 6px;
+ border-radius: 4px;
+}
+.sub-entry-remove:hover { background: rgba(248, 113, 113, 0.15); color: var(--accent-red, #f87171); }
+
+.sub-add-form {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.sub-add-form input {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 6px 10px;
+ color: var(--text);
+ font-size: 13px;
+}
+
+.sub-add-form input[type="text"] { width: 130px; }
+.sub-add-form input[type="number"] { width: 100px; }
+.sub-add-form input[type="date"] { width: 140px; }
+
+.sub-add-form button {
+ background: var(--accent-blue, #60a5fa);
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ padding: 6px 14px;
+ cursor: pointer;
+ font-size: 13px;
+ font-weight: 600;
+}
+.sub-add-form button:hover { opacity: 0.85; }
+
/* ── Update banner ──────────────────────────────────────────── */
.update-banner {