[object Object]

← 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

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=>({'<':'&lt;','>':'&gt;','&':'&amp;'}[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 →