From 5a44b58ffb8f251e1e7330e03ebe2fc6aabe495d Mon Sep 17 00:00:00 2001 From: Pawel Date: Mon, 6 Apr 2026 22:09:30 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BC=D1=83=D0=BB=D1=8C=D1=82=D0=B8?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B8=D0=BE=D0=B4=D0=BD=D0=BE=D0=B5=20=D1=81?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE?= =?UTF-8?q?=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 {