From 89c3e7c5d1eba7fbd0086a7da7121192401086a7 Mon Sep 17 00:00:00 2001 From: Pawel Date: Mon, 6 Apr 2026 22:03:47 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D1=82=D1=80=D0=B5=D0=BA=D0=B8?= =?UTF-8?q?=D0=BD=D0=B3=20=D0=BA=D1=8D=D1=88-=D1=82=D0=BE=D0=BA=D0=B5?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=20=D0=B8=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D1=82=D0=B0=20=D0=B2=20computeSessionCost?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Кэш-токены (cache_read, cache_create) трекаются отдельно от input - Добавлен расчёт утилизации контекстного окна (средний % по всем ходам, база 200K) - getCostAnalytics расширен: totalInputTokens, totalOutputTokens, totalCacheReadTokens, totalCacheCreateTokens, avgContextPct, dailyRate, byAgent, agentNoCostData - totalSessions теперь считает только сессии с реальными данными - Обратно совместимо: новые поля добавлены, старые не изменены Co-Authored-By: Claude Opus 4.6 --- src/data.js | 85 ++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 77 insertions(+), 8 deletions(-) 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, }; } From b670d1e3f1b866fbe7d662f3eedb06242321fce4 Mon Sep 17 00:00:00 2001 From: Pawel Date: Mon, 6 Apr 2026 22:07:45 +0300 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20UI=20=D1=80=D0=B0=D0=B7=D0=B1=D0=B8?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?,=20=D0=BF=D0=BE=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B5=20=D0=B0?= =?UTF-8?q?=D0=B3=D0=B5=D0=BD=D1=82=D0=BE=D0=B2,=20=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D1=8C=20=D0=BF=D0=BE=20=D0=B0?= =?UTF-8?q?=D0=B3=D0=B5=D0=BD=D1=82=D0=B0=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Карточки сводки: daily rate, убраны префиксы ~$ - Бейджи покрытия данных: Claude ✓ / Codex ~est. / OpenCode ✓|~est. / Cursor ✗ - Сетка Token Breakdown: input / output / cache read / cache write / avg context % - Диаграмма Cost by Agent (появляется при 2+ агентах с данными) - Кэш-токены в детальном просмотре сессии - CSS: .analytics-coverage, .token-breakdown-grid, .token-type-card Зависит от: feat/cost-cache-tokens-breakdown (расширенный API) Co-Authored-By: Claude Opus 4.6 --- src/frontend/app.js | 92 +++++++++++++++++++++++++++++++++++------ src/frontend/styles.css | 43 +++++++++++++++++++ 2 files changed, 123 insertions(+), 12 deletions(-) diff --git a/src/frontend/app.js b/src/frontend/app.js index 3519af5..eee3953 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -1277,10 +1277,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 +1829,61 @@ 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 usedof 200K
'; + } + 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 +1892,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 +1900,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 +1914,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 +1935,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..50a136c 100644 --- a/src/frontend/styles.css +++ b/src/frontend/styles.css @@ -2073,6 +2073,49 @@ 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); } + /* ── Update banner ──────────────────────────────────────────── */ .update-banner { From 3c5c3fb96943d8bc8e59def3da03c1141a20be50 Mon Sep 17 00:00:00 2001 From: Pawel Date: Mon, 6 Apr 2026 22:09:30 +0300 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=D0=BC=D1=83=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D0=B8=D0=BF=D0=B5=D1=80=D0=B8=D0=BE=D0=B4=D0=BD=D0=BE=D0=B5=20?= =?UTF-8?q?=D1=81=D1=80=D0=B0=D0=B2=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=BA=D0=B8=20vs=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Хелперы: getSubscriptionConfig() с миграцией старого формата, saveSubscriptionConfig(), subTotalPaid(), addSubEntry(), removeSubEntry() - localStorage ключ: codedash-subscription → {entries: [{plan, paid, from}]} - UI: карточки paid/API-equivalent/savings, прогресс-бар, ROI множитель - Список периодов с кнопкой удаления × и формой добавления - CSS: .subscription-section, .sub-comparison, .sub-card, .sub-bar-*, .sub-entry-*, .sub-add-form Зависит от: feat/token-breakdown-ui Co-Authored-By: Claude Opus 4.6 --- src/frontend/app.js | 78 +++++++++++++++++++++++++++++ src/frontend/styles.css | 106 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) diff --git a/src/frontend/app.js b/src/frontend/app.js index eee3953..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)); @@ -1881,6 +1909,56 @@ async function renderAnalytics(container) { 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 += ''; + html += '
'; + }); + } + html += '
'; + + // Add form + html += '
'; + html += ''; + html += ''; + html += ''; + html += ''; + html += '
'; + html += '
'; + // ── Daily cost chart ─────────────────────────────────────── var dayKeys = Object.keys(data.byDay).sort(); var last30 = dayKeys.slice(-30); diff --git a/src/frontend/styles.css b/src/frontend/styles.css index 50a136c..f2df9e1 100644 --- a/src/frontend/styles.css +++ b/src/frontend/styles.css @@ -2116,6 +2116,112 @@ body { .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 {