[object Object]

← back to AbramsEgo

budgets panel: MTD spend vs monthly targets (data/budgets.json + /api/budgets + dashboard)

bef5309a4f9cefe4081c15ec479331ebcad63963 · 2026-08-03 09:01:59 -0700 · Steve

- data/budgets.json seeds overall $200/mo + per-provider + CRE broker-scrape ($15/mo) targets
- collectBudgets() sums calendar-month-to-date ledger spend per line (app-prefix OR provider match; app-prefix claims a row first so CRE isn't double-counted under browserbase), computes %used/remaining/linear projection
- /api/budgets serves live (reflects budgets.json edits immediately); also folded into /api/data snapshot
- Budgets panel with per-line progress bars (green/amber/red) + overall MTD-vs-budget

Files touched

Diff

commit bef5309a4f9cefe4081c15ec479331ebcad63963
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Aug 3 09:01:59 2026 -0700

    budgets panel: MTD spend vs monthly targets (data/budgets.json + /api/budgets + dashboard)
    
    - data/budgets.json seeds overall $200/mo + per-provider + CRE broker-scrape ($15/mo) targets
    - collectBudgets() sums calendar-month-to-date ledger spend per line (app-prefix OR provider match; app-prefix claims a row first so CRE isn't double-counted under browserbase), computes %used/remaining/linear projection
    - /api/budgets serves live (reflects budgets.json edits immediately); also folded into /api/data snapshot
    - Budgets panel with per-line progress bars (green/amber/red) + overall MTD-vs-budget
---
 data/budgets.json |  16 ++++++++
 public/index.html |  46 +++++++++++++++++++++++
 server.js         | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++++-
 3 files changed, 167 insertions(+), 2 deletions(-)

diff --git a/data/budgets.json b/data/budgets.json
new file mode 100644
index 0000000..7965e76
--- /dev/null
+++ b/data/budgets.json
@@ -0,0 +1,16 @@
+{
+  "_note": "Monthly spend TARGETS (USD/mo). Steve-editable. Each line matches cost-ledger.jsonl rows by EITHER a provider key list (matches a row's api/model/vendor, case-insensitive) OR an app-prefix (matches a row's `app` field by startsWith). An app-prefix line takes the row FIRST (a matched app row is not double-counted under a provider line). MTD / %used / projected are computed server-side against the current calendar month.",
+  "overallMonthlyUsd": 200,
+  "lines": [
+    { "key": "cre_condo_brokers", "label": "CRE broker scrape (condo enrich)", "appPrefix": "commercialrealestate/enrich-condo-brokers", "monthlyUsd": 15 },
+    { "key": "browserbase", "label": "Browserbase (cloud browser)", "providers": ["browserbase"], "monthlyUsd": 40 },
+    { "key": "gemini", "label": "Gemini (vision/image)", "providers": ["gemini_2_5_flash", "gemini-2.5-flash-image", "gemini-image", "gemini-2.5-flash-lite", "gemini-3.5-flash", "room_render"], "monthlyUsd": 40 },
+    { "key": "openai_codex", "label": "OpenAI / Codex", "providers": ["openai_gpt5_3_codex", "gpt-5.1"], "monthlyUsd": 40 },
+    { "key": "moonshot", "label": "Moonshot Kimi", "providers": ["moonshot_kimi_k25", "kimi-k2.5"], "monthlyUsd": 15 },
+    { "key": "grok", "label": "Grok (xAI)", "providers": ["grok-4.5"], "monthlyUsd": 20 },
+    { "key": "elevenlabs", "label": "ElevenLabs (voice)", "providers": ["elevenlabs_pro"], "monthlyUsd": 15 },
+    { "key": "replicate", "label": "Replicate (SDXL / video)", "providers": ["replicate_sdxl", "bytedance/seedance-1-pro", "stability-ai/sdxl"], "monthlyUsd": 15 },
+    { "key": "exa", "label": "Exa (web search)", "providers": ["exa_search", "websearch_builtin"], "monthlyUsd": 10 },
+    { "key": "twilio", "label": "Twilio / voice (SMS/call)", "providers": ["twilio", "vapi_voice"], "monthlyUsd": 10 }
+  ]
+}
diff --git a/public/index.html b/public/index.html
index 9a281f9..94760d9 100644
--- a/public/index.html
+++ b/public/index.html
@@ -298,6 +298,13 @@
     <div style="margin-top:10px" id="costapps"></div>
   </section>
 
+  <!-- BUDGETS (spend-vs-target, month-to-date) -->
+  <section class="card span8" id="budgets-card">
+    <h2>🎯 Budgets <span class="muted" id="budgets-sub">· month-to-date vs monthly target</span></h2>
+    <div class="stats" id="budgets-overall"><div class="muted">loading…</div></div>
+    <div style="margin-top:12px" id="budgets-lines"></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>
@@ -525,6 +532,9 @@ async function load(){
   // ---- cost trend · 7d (pure-SVG area chart) + per-model 30d ----
   renderCostTrend(d);
 
+  // ---- budgets (spend-vs-target, MTD) ----
+  renderBudgets(d);
+
   // ---- model usage + sub-agent runs (panel 5) ----
   renderUsage(d);
 
@@ -590,6 +600,42 @@ async function load(){
 // Palette cycles the theme accent colors; each provider always shows its $ figure
 // (Steve's "always show costs" rule) and free/local buckets read "$0 (local)".
 const PROV_COLORS=['#4d9fff','#3fd18b','#ffb454','#ff5d6c','#ffd166','#a78bfa','#22d3ee','#f472b6','#94a3b8'];
+
+// ---- BUDGETS: month-to-date spend vs monthly target -----------------------
+// One row per budget line with a progress bar (green under / amber ~80%+ / red
+// over budget), $ MTD / $ budget, % used, and a linear projected month-end.
+// Overall MTD-vs-budget shown first. All $ figures labeled (Steve's cost rule).
+function budgetColor(status){
+  return status==='over'?'var(--bad)':status==='warn'?'var(--warn)':status==='nolimit'?'var(--dim)':'var(--good)';
+}
+function renderBudgets(d){
+  const b=d.budgets||{};
+  const ov=b.overall||{};
+  const sub=$('budgets-sub');
+  if(sub) sub.textContent=`· ${b.month||''} · day ${b.dayOfMonth||'—'}/${b.daysInMonth||'—'} · MTD vs monthly target`;
+  // overall stat cards
+  const oc=budgetColor(ov.status);
+  const opct=ov.pctUsed==null?0:ov.pctUsed;
+  $('budgets-overall').innerHTML = `
+    <div class="stat"><div class="k">MTD spend</div><div class="v" style="color:${oc}">${usd(ov.mtdUsd)}</div><div class="muted">of ${usd(ov.budgetUsd)}/mo budget</div></div>
+    <div class="stat"><div class="k">% used</div><div class="v" style="color:${oc}">${ov.pctUsed==null?'—':ov.pctUsed+'%'}</div><div class="muted">${(ov.remainingUsd||0)>=0?usd(ov.remainingUsd)+' remaining':usd(-ov.remainingUsd)+' over'}</div></div>
+    <div class="stat"><div class="k">Projected month-end</div><div class="v">${usd(ov.projectedUsd)}</div><div class="muted">MTD ÷ day × days-in-month</div></div>
+    <div class="bar" title="overall month-to-date vs budget" style="grid-column:1/-1;margin-top:2px"><span style="width:${Math.min(100,opct)}%;background:${oc}"></span></div>`;
+  // per-line rows
+  const lines=(b.lines||[]);
+  if(!lines.length){ $('budgets-lines').innerHTML='<span class="muted">no budget lines configured</span>'; return; }
+  $('budgets-lines').innerHTML = lines.map(ln=>{
+    const col=budgetColor(ln.status);
+    const pct=ln.pctUsed==null?0:ln.pctUsed;
+    const over=ln.status==='over';
+    return `<div class="eng" style="border-bottom:none;padding-bottom:2px">
+        <span title="${ln.match}">${ln.label} <span class="muted">${ln.match}</span></span>
+        <span><b style="color:${col}">${usd(ln.mtdUsd)}</b> <span class="muted">/ ${usd(ln.budgetUsd)}</span> · <span style="color:${col}">${ln.pctUsed==null?'—':ln.pctUsed+'%'}</span> · <span class="muted">proj ${usd(ln.projectedUsd)}</span></span>
+      </div>
+      <div class="bar" title="${ln.label}: ${usd(ln.mtdUsd)} of ${usd(ln.budgetUsd)}${over?' — OVER budget':''}"><span style="width:${Math.min(100,pct)}%;background:${col}"></span></div>`;
+  }).join('');
+}
+
 function renderProviderDonut(d){
   const el=$('costproviders'); if(!el) return;
   const bp=((d.cost&&d.cost.byProvider)||[]).slice();
diff --git a/server.js b/server.js
index 8b966f7..9b6663e 100644
--- a/server.js
+++ b/server.js
@@ -49,6 +49,7 @@ const ENERGY_ATTRIB = Math.min(1, Math.max(0, parseFloat(process.env.ENERGY_ATTR
 const CNCP = process.env.CNCP_BASE || 'http://127.0.0.1:3333';
 const KAMATERA = process.env.KAMATERA_HOST || '45.61.58.125';
 const COST_LEDGER = path.join(HOME, '.claude', 'cost-ledger.jsonl');
+const BUDGETS_FILE = path.join(DATA_DIR, 'budgets.json');
 
 // --- Stripe TEST-mode checkout rail (engine 1: sell_product) -----------------
 // HARD RAIL: TEST keys only. A live key (sk_live_) means Steve is flipping the
@@ -317,7 +318,7 @@ async function collectCanaries() {
             else if (j.fail === 0 || j.leaking === 0 || j.dead === 0 || j.stale === 0) verdict = 'PASS';
           }
         }
-        scanned = j.scanned_at || j.checked_at || j.ran_at || j.ts || j.at || null;
+        scanned = j.scanned_at || j.checked_at || j.ran_at || j.generated_at || j.ts || j.at || null;
       } catch (e) {}
       const ageMin = Math.round((Date.now() - st.mtimeMs) / 60000);
       // heartbeat/last-run time so each canary card can show created date+time
@@ -705,6 +706,104 @@ function collectCost() {
   return out;
 }
 
+// --- BUDGETS (spend-vs-target) ---------------------------------------------
+// data/budgets.json holds monthly TARGETS; this sums MONTH-TO-DATE spend from
+// the cost ledger against each line and computes %used / remaining / a simple
+// linear projection to month-end. Read-only against the ledger; budgets.json is
+// the only writable file. A line matches a ledger row by EITHER an app-prefix
+// (row.app startsWith) OR a provider key list (row api/model/vendor). App-prefix
+// lines claim a row FIRST so a CRE-scrape row isn't double-counted under the
+// broader browserbase provider line.
+function loadBudgets() {
+  try { return JSON.parse(fs.readFileSync(BUDGETS_FILE, 'utf8')); }
+  catch (e) { return { overallMonthlyUsd: 0, lines: [] }; }
+}
+function collectBudgets() {
+  const cfg = loadBudgets();
+  const lines = Array.isArray(cfg.lines) ? cfg.lines : [];
+  // calendar-month window (LOCAL), matching the "today" the dashboard uses
+  const now = new Date();
+  const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
+  const dayOfMonth = now.getDate();
+  const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
+  const monthLabel = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
+
+  // precompute per-line MTD accumulators
+  const acc = lines.map(() => 0);
+  const appLineIdx = [];   // [{prefix, idx}] — checked first, longest-prefix wins
+  const provMap = {};      // lowercased provider key -> line idx
+  lines.forEach((ln, i) => {
+    if (ln.appPrefix) appLineIdx.push({ prefix: ln.appPrefix, idx: i });
+    for (const p of (ln.providers || [])) provMap[String(p).toLowerCase()] = i;
+  });
+  appLineIdx.sort((a, b) => b.prefix.length - a.prefix.length);
+
+  let overallMtd = 0;
+  try {
+    const rows = fs.readFileSync(COST_LEDGER, 'utf8').split('\n');
+    for (const raw of rows) {
+      if (!raw.trim()) continue;
+      let j; try { j = JSON.parse(raw); } catch (e) { continue; }
+      const t = Date.parse(j.ts);
+      if (isNaN(t) || t < monthStart) continue;
+      const c = j.cost_usd || 0;
+      overallMtd += c;
+      // 1) app-prefix match takes the row first
+      const app = String(j.app || '');
+      let idx = -1;
+      for (const a of appLineIdx) { if (app.startsWith(a.prefix)) { idx = a.idx; break; } }
+      // 2) else provider match by api/model/vendor
+      if (idx < 0) {
+        for (const k of [j.api, j.model, j.vendor]) {
+          if (k && provMap[String(k).toLowerCase()] != null) { idx = provMap[String(k).toLowerCase()]; break; }
+        }
+      }
+      if (idx >= 0) acc[idx] += c;
+    }
+  } catch (e) {}
+
+  const project = (mtd) => dayOfMonth > 0 ? round((mtd / dayOfMonth) * daysInMonth, 4) : 0;
+  const status = (mtd, budget) => {
+    if (!budget) return 'nolimit';
+    const pct = mtd / budget * 100;
+    if (pct >= 100) return 'over';
+    if (pct >= 80) return 'warn';
+    return 'ok';
+  };
+  const outLines = lines.map((ln, i) => {
+    const mtd = round(acc[i], 4);
+    const budget = ln.monthlyUsd || 0;
+    const proj = project(mtd);
+    return {
+      key: ln.key, label: ln.label,
+      match: ln.appPrefix ? `app: ${ln.appPrefix}` : `provider: ${(ln.providers || []).join(', ')}`,
+      budgetUsd: round(budget, 2),
+      mtdUsd: mtd,
+      pctUsed: budget ? round(mtd / budget * 100, 1) : null,
+      remainingUsd: round(budget - mtd, 4),
+      projectedUsd: proj,
+      status: status(mtd, budget),
+    };
+  }).sort((a, b) => (b.pctUsed || 0) - (a.pctUsed || 0));
+
+  const overallBudget = cfg.overallMonthlyUsd || 0;
+  overallMtd = round(overallMtd, 4);
+  return {
+    updatedAt: new Date().toISOString(),
+    month: monthLabel,
+    dayOfMonth, daysInMonth,
+    overall: {
+      budgetUsd: round(overallBudget, 2),
+      mtdUsd: overallMtd,
+      pctUsed: overallBudget ? round(overallMtd / overallBudget * 100, 1) : null,
+      remainingUsd: round(overallBudget - overallMtd, 4),
+      projectedUsd: project(overallMtd),
+      status: status(overallMtd, overallBudget),
+    },
+    lines: outLines,
+  };
+}
+
 // --- REVENUE side of the P&L (five gated engines) --------------------------
 // Honest licensing-engine status: count the REAL in-house original designs that
 // have been generated + settlement-passed in the Pattern Vault (sibling project).
@@ -873,6 +972,7 @@ async function doBuild() {
     wrap(() => fetchJSON(`${CNCP}/api/officers`)),
   ]);
   const cost = collectCost();
+  const budgets = (() => { try { return collectBudgets(); } catch (e) { return { error: e.message }; } })();
   const revenue = collectRevenue();
   const pnl = buildPnL(cost, revenue);
   const usage = (() => { try { return collectUsage(); } catch (e) { return { error: e.message }; } })();
@@ -883,7 +983,7 @@ async function doBuild() {
     sessions: summarizeSessions(sessions),
     wins: summarizeWins(wins),
     officers: summarizeOfficers(officers),
-    cost, revenue, pnl, usage,
+    cost, budgets, revenue, pnl, usage,
     // compact form folded into the snapshot for at-a-glance headers
     usageSummary: {
       activeSessions: usage.sessions && usage.sessions.active,
@@ -1011,6 +1111,9 @@ app.use((req, res, next) => {
 
 app.get('/api/healthz', (req, res) => res.json({ ok: true, service: 'abramsego', version: VERSION, uptimeSec: Math.round((Date.now() - START_TS) / 1000), snapshotAt: SNAP.builtAt }));
 app.get('/api/data', (req, res) => { res.json(SNAP); });
+// budgets computed live so a data/budgets.json edit reflects immediately
+// (also folded into /api/data for the dashboard's single fetch).
+app.get('/api/budgets', (req, res) => { try { res.json(collectBudgets()); } catch (e) { res.status(500).json({ error: e.message }); } });
 app.get('/api/refresh', async (req, res) => { const s = await buildSnapshot(); res.json(s); });
 
 // WPB marketplace sales — proxied server-to-server from the wpb-sales-dashboard (:9812).

← ca7c066 auto-save: 2026-08-03T08:52:19 (3 files) — data/affiliate-cl  ·  back to AbramsEgo  ·  Add cre_condos_redfin budget line ($18/mo, app-prefix, separ dd302da →