← 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
A data/budgets.jsonM public/index.htmlM server.js
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 →