← back to AbramsEgo
feat: 7d cost trend + per-model breakdown
6623e26a0caaaa7463239498168e6b9f9794ab3a · 2026-07-01 10:42:57 -0700 · Steve
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
M public/index.htmlM server.js
Diff
commit 6623e26a0caaaa7463239498168e6b9f9794ab3a
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Jul 1 10:42:57 2026 -0700
feat: 7d cost trend + per-model breakdown
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
public/index.html | 48 ++++++++++++++++++++++++++++++++++++++++++++++++
server.js | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 102 insertions(+)
diff --git a/public/index.html b/public/index.html
index a750855..12012ad 100644
--- a/public/index.html
+++ b/public/index.html
@@ -89,6 +89,15 @@
<div style="margin-top:10px" id="costapps"></div>
</section>
+ <!-- COST TREND · 7d + PER-MODEL -->
+ <section class="card span12">
+ <h2>📈 Cost trend · 7d <span class="muted">· daily total (tokens + energy est) · per-model 30d</span></h2>
+ <div style="display:flex;gap:18px;flex-wrap:wrap;align-items:flex-start">
+ <div style="flex:2;min-width:280px" id="trend"><span class="muted">loading…</span></div>
+ <div style="flex:1;min-width:200px" id="bymodel"></div>
+ </div>
+ </section>
+
<!-- FLEET -->
<section class="card span4">
<h2>🛰 Fleet · Kamatera</h2>
@@ -179,6 +188,9 @@ async function load(){
const max=Math.max(...apps.map(a=>a[1]),0.0001);
$('costapps').innerHTML=apps.map(([a,v])=>`<div class="row"><span class="dim">${a}</span><span>${usd(v)}</span></div><div class="bar"><span style="width:${Math.round(v/max*100)}%"></span></div>`).join('')||'<span class="muted">no cost data</span>';
+ // ---- cost trend · 7d (pure-SVG area chart) + per-model 30d ----
+ renderCostTrend(d);
+
// ---- fleet ----
const k=d.kamatera||{}, l=d.localFleet||{};
$('kam-up').innerHTML=`<span class="${cls(100-(k.up/(k.total||1)*100),20,50)}">${k.up??'—'}</span><span class="dim" style="font-size:16px">/${k.total??'—'} up</span>`;
@@ -219,6 +231,42 @@ async function load(){
if((p.selfFundingPct||0)<100) alerts.push('not yet self-funding ('+ (p.selfFundingPct||0) +'% of cost covered)');
const a=$('alert'); if(alerts.length){a.classList.add('show');a.textContent='⚠ '+alerts.join(' · ');}else{a.classList.remove('show');}
}
+function renderCostTrend(d){
+ const series=(d.cost&&d.cost.series7d)||[];
+ const el=$('trend');
+ if(el){
+ if(!series.length){ el.innerHTML='<span class="muted">no cost data</span>'; }
+ else{
+ const W=560,H=150,padL=10,padR=10,padT=14,padB=24;
+ const iw=W-padL-padR, ih=H-padT-padB, n=series.length;
+ const vals=series.map(s=>s.total), max=Math.max(...vals,0.0001);
+ const x=i=> padL + (n<=1?iw/2:(i/(n-1))*iw);
+ const y=v=> padT + ih - (v/max)*ih;
+ const pts=series.map((s,i)=>[x(i),y(s.total)]);
+ const line=pts.map((p,i)=>(i?'L':'M')+p[0].toFixed(1)+' '+p[1].toFixed(1)).join(' ');
+ const base=(padT+ih).toFixed(1);
+ const area='M '+pts[0][0].toFixed(1)+' '+base+' '+pts.map(p=>'L'+p[0].toFixed(1)+' '+p[1].toFixed(1)).join(' ')+' L '+pts[n-1][0].toFixed(1)+' '+base+' Z';
+ const dots=pts.map((p,i)=>`<circle cx="${p[0].toFixed(1)}" cy="${p[1].toFixed(1)}" r="2.6" fill="var(--accent)"><title>${series[i].day}: ${usd(series[i].total)}</title></circle>`).join('');
+ const labels=series.map((s,i)=>`<text x="${x(i).toFixed(1)}" y="${H-7}" fill="var(--dim)" font-size="9" text-anchor="middle">${s.day.slice(5)}</text>`).join('');
+ const peak=`<text x="${padL}" y="${padT-2}" fill="var(--dim)" font-size="9">max ${usd(max)}</text>`;
+ el.innerHTML=`<svg viewBox="0 0 ${W} ${H}" width="100%" style="display:block;height:auto">
+ <defs><linearGradient id="cg" x1="0" y1="0" x2="0" y2="1">
+ <stop offset="0" stop-color="var(--accent)" stop-opacity="0.35"/>
+ <stop offset="1" stop-color="var(--accent)" stop-opacity="0"/></linearGradient></defs>
+ <path d="${area}" fill="url(#cg)"/>
+ <path d="${line}" fill="none" stroke="var(--accent)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
+ ${dots}${labels}${peak}
+ </svg>`;
+ }
+ }
+ const bm=(d.cost&&d.cost.byModel)||[];
+ const bel=$('bymodel');
+ if(bel){
+ const mmax=Math.max(...bm.map(m=>m.usd),0.0001);
+ bel.innerHTML='<div class="muted" style="margin-bottom:6px">per-model · 30d</div>'+
+ (bm.slice(0,8).map(m=>`<div class="row"><span class="dim">${esc(m.api)}</span><span>${usd(m.usd)}</span></div><div class="bar"><span style="width:${Math.round(m.usd/mmax*100)}%"></span></div>`).join('')||'<span class="muted">no model data</span>');
+ }
+}
function statusDot(st){const c=st==='online'?'var(--good)':st?'var(--bad)':'var(--dim)';return `<span class="dot" style="background:${c}"></span>`;}
function fmtDur(s){if(s==null)return null;if(s<60)return s+'s';if(s<3600)return Math.floor(s/60)+'m';return Math.floor(s/3600)+'h';}
function esc(x){return (x==null?'':String(x)).replace(/[<>&]/g,m=>({'<':'<','>':'>','&':'&'}[m]));}
diff --git a/server.js b/server.js
index d5ad62b..e16f2f3 100644
--- a/server.js
+++ b/server.js
@@ -212,6 +212,57 @@ function energyCost(sinceMs) {
const kwh = (ENERGY_AVG_WATTS / 1000) * hours;
return { hours: round(hours, 1), kwh: round(kwh, 3), usd: round(kwh * ENERGY_RATE, 4), est: true, avgWatts: ENERGY_AVG_WATTS, rate: ENERGY_RATE };
}
+// 7-day daily buckets of cost (tokens+energy) + a per-api/model breakdown for
+// 30d. One pass over the ledger keeps this cheap. Days are keyed by LOCAL
+// calendar day so the trend lines up with the "today" the dashboard shows.
+function collectCostSeries() {
+ const now = Date.now();
+ const dayKey = (ms) => {
+ const dt = new Date(ms);
+ return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
+ };
+ // pre-seed the 7 buckets oldest→newest so gap days render as $0, not missing
+ const buckets = [];
+ const byKey = {};
+ for (let i = 6; i >= 0; i--) {
+ const key = dayKey(now - i * 24 * 3.6e6);
+ const b = { day: key, tokens: 0 };
+ buckets.push(b); byKey[key] = b;
+ }
+ const since30 = now - 30 * 24 * 3.6e6;
+ const byModel = {};
+ try {
+ const lines = fs.readFileSync(COST_LEDGER, 'utf8').split('\n');
+ for (const ln of lines) {
+ if (!ln.trim()) continue;
+ let j; try { j = JSON.parse(ln); } catch (e) { continue; }
+ const t = Date.parse(j.ts);
+ if (isNaN(t)) continue;
+ const c = j.cost_usd || 0;
+ const key = dayKey(t);
+ if (byKey[key]) byKey[key].tokens += c; // 7d daily buckets
+ if (t >= since30) { // 30d per-model breakdown
+ const m = j.api || j.model || 'unknown';
+ byModel[m] = (byModel[m] || 0) + c;
+ }
+ }
+ } catch (e) {}
+ // energy per day: a full 24h for past days, partial (midnight→now) for today
+ const perDayFullUsd = round((ENERGY_AVG_WATTS / 1000) * 24 * ENERGY_RATE, 4);
+ const sod = new Date(); sod.setHours(0, 0, 0, 0);
+ const todayHours = (now - sod.getTime()) / 3.6e6;
+ const todayUsd = round((ENERGY_AVG_WATTS / 1000) * todayHours * ENERGY_RATE, 4);
+ const todayKey = dayKey(now);
+ const series7d = buckets.map((b) => {
+ const energy = b.day === todayKey ? todayUsd : perDayFullUsd;
+ return { day: b.day, tokens: round(b.tokens, 4), energy, total: round(b.tokens + energy, 4) };
+ });
+ const byModelArr = Object.entries(byModel)
+ .sort((a, b) => b[1] - a[1])
+ .map(([api, usd]) => ({ api, usd: round(usd, 4) }));
+ return { series7d, byModel: byModelArr };
+}
+
function collectCost() {
const now = Date.now();
const windows = { today: 24 * 3.6e6, week: 7 * 24 * 3.6e6, month: 30 * 24 * 3.6e6 };
@@ -222,6 +273,9 @@ function collectCost() {
const energy = energyCost(since);
out[k] = { tokens: tokens.total, tokensCount: tokens.count, energy: energy.usd, energyKwh: energy.kwh, total: round(tokens.total + energy.usd, 4), byApp: tokens.byApp };
}
+ const series = collectCostSeries();
+ out.series7d = series.series7d;
+ out.byModel = series.byModel;
return out;
}
← 3534c2e auto-save: 2026-07-01T10:41:35 (1 files) — build-queue/.loop
·
back to AbramsEgo
·
feat: energy attribution + break-even target in P&L 42aceb2 →