Release v0.51.323 — Release KM (7-day provider spend chart, #3600) (#3804)

#3600 (@rodboev): 7-day spend chart + monthly pace in the provider quota card. UX-approved by Nathan. Full suite 8235, Codex SAFE (backend contract verified), Opus SHIP + refresh-keeps-chart fix (live-verified). CI 11/11. Co-authored-by: rodboev <rodboev@users.noreply.github.com>
This commit is contained in:
nesquena-hermes
2026-06-07 17:41:59 -07:00
committed by GitHub
parent ee982a7581
commit 1d5c054815
4 changed files with 84 additions and 1 deletions

View File

@@ -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

View File

@@ -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='<div class="provider-cost-chart-title">7-day spend</div><div class="provider-quota-message">Not enough data yet. Cost chart builds after 2 daily snapshots.</div>';
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 `<div class="insights-daily-bar" title="${esc(tip)}"><div class="insights-daily-stack" aria-label="${esc(tip)}"><div class="insights-daily-bar-input" style="height:${pct}%"></div></div><span>${esc(label)}</span></div>`;
}).join('');
const wrap=document.createElement('div');
wrap.className='provider-cost-chart-wrap';
wrap.innerHTML=`<div class="provider-cost-chart-title">7-day spend <span class="provider-cost-chart-pace">Monthly pace: ${esc(pace)}</span></div><div class="provider-cost-chart-bars insights-daily-token-chart">${bars}</div>`;
body.appendChild(wrap);
}
function _buildProviderCard(p){
const card=document.createElement('div');
card.className='provider-card';

View File

@@ -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;

View File

@@ -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