diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dfa7fd5..20ca374c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ ## [Unreleased] +## [v0.51.323] — 2026-06-07 — Release KM (7-day provider spend chart) + +### Added +- **The Active Provider Quota card now shows a 7-day spend bar chart with a projected monthly pace** (OpenRouter). Daily spend deltas render as a small bar chart with a "Monthly pace" estimate; a graceful "not enough data yet" message shows until two daily snapshots exist. (#3600, @rodboev) + ## [v0.51.322] — 2026-06-07 — Release KL (sortable + filterable markdown tables) ### Added diff --git a/static/panels.js b/static/panels.js index 87ab6080..6cc0abf6 100644 --- a/static/panels.js +++ b/static/panels.js @@ -6863,7 +6863,10 @@ async function loadProvidersPanel(){ list.innerHTML=''; _providerCardEls.clear(); const quotaCard=_buildProviderQuotaCard(quota); - if(quotaCard) list.appendChild(quotaCard); + if(quotaCard){ + list.appendChild(quotaCard); + renderProviderCostChart(quotaCard); // async, fire-and-forget + } if(providers.length===0){ list.style.display='none'; if(empty) empty.style.display=''; @@ -6899,6 +6902,10 @@ async function _refreshProviderQuota(card,button){ const fresh=_buildProviderQuotaCard(next); if(fresh){ card.replaceWith(fresh); + // Re-render the 7-day spend chart onto the rebuilt card — the quota + // refresh replaces the whole card, which would otherwise drop the chart + // until the next full panel reload (#3600). + renderProviderCostChart(fresh); // async, fire-and-forget if(typeof showToast==='function') showToast(failed?t('provider_quota_refresh_failed'):t('provider_quota_refresh_succeeded')); return; } @@ -7125,6 +7132,43 @@ function _buildProviderQuotaCard(status){ return card; } +async function renderProviderCostChart(card){ + let history; + try{ + history=await api('/api/provider/cost-history?provider=openrouter'); + }catch(e){ + return; // silently skip if endpoint unavailable + } + const body=card.querySelector('.provider-quota-body'); + if(!body||body.querySelector('.provider-cost-chart-wrap')) return; + if(!history||history.ok===false) return; + const snaps=Array.isArray(history.snapshots)?history.snapshots:[]; + // need at least 2 snapshots to have one non-null delta + const hasData=snaps.filter(s=>s.delta!=null).length>=1; + if(!hasData){ + const empty=document.createElement('div'); + empty.className='provider-cost-chart-wrap'; + empty.innerHTML='
7-day spend
Not enough data yet. Cost chart builds after 2 daily snapshots.
'; + body.appendChild(empty); + return; + } + const maxDelta=Math.max(...snaps.map(s=>s.delta!=null?Number(s.delta):0),1e-9); + const nonNull=snaps.filter(s=>s.delta!=null).map(s=>Number(s.delta)); + const avg=nonNull.length?nonNull.reduce((a,b)=>a+b,0)/nonNull.length:0; + const pace='$'+(avg*30).toFixed(2); + const bars=snaps.map(s=>{ + const delta=s.delta!=null?Number(s.delta):null; + const pct=delta!=null?Math.max((delta/maxDelta)*100,delta>0?2:0).toFixed(1):'0'; + const label=String(s.date||'').slice(5); + const tip=delta!=null?`${s.date} · $${delta.toFixed(4)}`:`${s.date} · no baseline`; + return `
${esc(label)}
`; + }).join(''); + const wrap=document.createElement('div'); + wrap.className='provider-cost-chart-wrap'; + wrap.innerHTML=`
7-day spend Monthly pace: ${esc(pace)}
${bars}
`; + body.appendChild(wrap); +} + function _buildProviderCard(p){ const card=document.createElement('div'); card.className='provider-card'; diff --git a/static/style.css b/static/style.css index aab17bd8..103c06a1 100644 --- a/static/style.css +++ b/static/style.css @@ -3853,6 +3853,10 @@ main.main > #mainPlugin{display:none;} .provider-quota-pool-row-head span{white-space:normal;} .provider-quota-pool-windows{grid-template-columns:1fr;} } +.provider-cost-chart-wrap{width:100%;margin-top:12px;} +.provider-cost-chart-title{font-size:11px;font-weight:650;color:var(--muted);margin-bottom:6px;display:flex;align-items:center;justify-content:space-between;gap:8px;} +.provider-cost-chart-pace{font-weight:400;color:var(--text);} +.provider-cost-chart-bars{height:80px;} .provider-card{ border:1px solid var(--border); border-radius:12px; diff --git a/tests/test_provider_cost_chart_ui.py b/tests/test_provider_cost_chart_ui.py new file mode 100644 index 00000000..e41b6aed --- /dev/null +++ b/tests/test_provider_cost_chart_ui.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def test_provider_cost_chart_ui_guards_are_present(): + panels_js = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8") + style_css = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8") + + # function is defined + assert "async function renderProviderCostChart(card)" in panels_js + + # function is wired up inside loadProvidersPanel (fire-and-forget) + assert "renderProviderCostChart(quotaCard)" in panels_js + + # fetch target is correct + assert "/api/provider/cost-history?provider=openrouter" in panels_js + + # CSS container class present in both JS and CSS + assert "provider-cost-chart-wrap" in panels_js + assert "provider-cost-chart-wrap" in style_css + + # monthly pace projection annotation + assert "Monthly pace" in panels_js + + # null delta guard for the oldest snapshot + assert "s.delta!=null" in panels_js