[object Object]

← back to Re Coverage Dashboard

re-coverage-dashboard: liquid-fill gauges for $0/local RE contact-discovery coverage (usre+CRCP) + milestone/win tracking + fill.sh filler loop

7751daae72109cb3fae33b00cc95866cfb43c6fc · 2026-08-06 12:04:26 -0700 · Steve Abrams

Files touched

Diff

commit 7751daae72109cb3fae33b00cc95866cfb43c6fc
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Aug 6 12:04:26 2026 -0700

    re-coverage-dashboard: liquid-fill gauges for $0/local RE contact-discovery coverage (usre+CRCP) + milestone/win tracking + fill.sh filler loop
---
 .gitignore        |   5 +++
 fill.sh           |  21 +++++++++
 package.json      |  10 +++++
 public/index.html | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js         | 105 +++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 271 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..29070bc
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+.env*
+*.log
+data/
+.DS_Store
diff --git a/fill.sh b/fill.sh
new file mode 100755
index 0000000..a4d6036
--- /dev/null
+++ b/fill.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+# $0/local contact-coverage filler. Alternates CRCP + usre agent-site discovery in
+# SMALL batches with long cooldowns so the free SERP doesn't stay bot-walled (it
+# throttle-walls after ~9 rapid queries). Runs forever; fully resumable — each tool
+# skips already-attempted rows, so a kill/restart just continues. No metered spend.
+set -uo pipefail
+export OLLAMA_MODEL=${OLLAMA_MODEL:-qwen3:14b}
+CRCP="$HOME/Projects/commercialrealestate"
+USRE="$HOME/Projects/nationalrealestate"
+COOLDOWN=${COOLDOWN:-420}   # 7 min between batches — lets DDG/Brave cool down
+BATCH=${BATCH:-8}           # small: stops just under the ~9-query throttle wall
+
+echo "[filler] starting — batch=$BATCH cooldown=${COOLDOWN}s model=$OLLAMA_MODEL ($0/local)"
+while true; do
+  echo "[$(date '+%F %H:%M:%S')] CRCP batch"
+  ( cd "$CRCP" && node scripts/discover-agent-sites.js --limit="$BATCH" ) 2>&1 | grep -E '✓|done|throttl' | tail -4 || true
+  sleep "$COOLDOWN"
+  echo "[$(date '+%F %H:%M:%S')] usre batch"
+  ( cd "$USRE" && npx tsx src/enrich/broker_website_discovery.ts --limit="$BATCH" ) 2>&1 | grep -E '✓|done|throttl' | tail -4 || true
+  sleep "$COOLDOWN"
+done
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..8262413
--- /dev/null
+++ b/package.json
@@ -0,0 +1,10 @@
+{
+  "name": "re-coverage-dashboard",
+  "version": "0.1.0",
+  "private": true,
+  "description": "Liquid-fill coverage dashboard for the $0/local RE agent contact-discovery (usre + CRCP) + win tracking.",
+  "scripts": {
+    "start": "node server.js",
+    "fill": "bash fill.sh"
+  }
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..3a24062
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,130 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>RE Contact Coverage — liquid progress</title>
+<style>
+  :root { --bg:#0e1116; --card:#161b22; --line:#232a33; --ink:#e6edf3; --mut:#8b98a6; --acc:#3fb950; --blue:#58a6ff; }
+  * { box-sizing:border-box; }
+  body { margin:0; background:var(--bg); color:var(--ink); font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; }
+  header { padding:20px 24px 8px; }
+  h1 { font-size:20px; margin:0; font-weight:650; letter-spacing:.2px; }
+  .sub { color:var(--mut); font-size:12px; margin-top:3px; }
+  .totals { display:flex; gap:22px; margin-top:12px; flex-wrap:wrap; }
+  .tot { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:8px 14px; }
+  .tot b { font-size:19px; color:var(--acc); } .tot .l { font-size:11px; color:var(--mut); display:block; }
+  .grp { padding:6px 24px 0; }
+  .grp h2 { font-size:12px; text-transform:uppercase; letter-spacing:1px; color:var(--mut); margin:16px 0 4px; }
+  .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); gap:14px; }
+  .card { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:14px; text-align:center; }
+  .card canvas { width:130px; height:130px; }
+  .card .lab { font-size:12.5px; font-weight:600; margin-top:6px; }
+  .card .cnt { font-size:11px; color:var(--mut); margin-top:2px; }
+  .wins { padding:8px 24px 30px; }
+  .wins h2 { font-size:12px; text-transform:uppercase; letter-spacing:1px; color:var(--mut); margin:18px 0 8px; }
+  .win { display:flex; gap:10px; align-items:center; background:var(--card); border:1px solid var(--line); border-left:3px solid var(--acc); border-radius:8px; padding:8px 12px; margin-bottom:6px; }
+  .win .t { flex:1; } .win .when { font-size:11px; color:var(--mut); }
+  .empty { color:var(--mut); font-size:13px; padding:8px 0; }
+</style>
+</head>
+<body>
+<header>
+  <h1>🌊 RE Contact Coverage</h1>
+  <div class="sub">Filling in phone · firm · site · agent-site across usre + CRCP — $0/local discovery. <span id="upd"></span></div>
+  <div class="totals" id="totals"></div>
+</header>
+<div id="groups"></div>
+<div class="wins"><h2>🏆 Wins / milestones</h2><div id="winlist"><div class="empty">no milestones yet — they appear as coverage crosses thresholds</div></div></div>
+
+<script>
+// ── Liquid-fill canvas gauge: a clipped circle with an animated sine-wave surface. ──
+class LiquidGauge {
+  constructor(canvas, color) {
+    this.c = canvas; this.ctx = canvas.getContext('2d');
+    const dpr = window.devicePixelRatio || 1; this.s = 130;
+    canvas.width = this.s * dpr; canvas.height = this.s * dpr; this.ctx.scale(dpr, dpr);
+    this.color = color; this.level = 0; this.target = 0; this.phase = 0;
+    requestAnimationFrame(() => this.draw());
+  }
+  setTarget(pct) { this.target = Math.max(0, Math.min(100, pct)); }
+  draw() {
+    const { ctx, s } = this, r = s / 2 - 6, cx = s / 2, cy = s / 2;
+    this.level += (this.target - this.level) * 0.06;      // ease toward target
+    this.phase += 0.045;
+    ctx.clearRect(0, 0, s, s);
+    // ring
+    ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2);
+    ctx.strokeStyle = 'rgba(255,255,255,.10)'; ctx.lineWidth = 3; ctx.stroke();
+    // water clipped to circle
+    ctx.save();
+    ctx.beginPath(); ctx.arc(cx, cy, r - 1, 0, Math.PI * 2); ctx.clip();
+    const waterY = cy + r - (this.level / 100) * (2 * r);
+    const amp = 4 + (this.level > 1 && this.level < 99 ? 2 : 0);
+    for (let k = 0; k < 2; k++) {
+      ctx.beginPath();
+      ctx.moveTo(cx - r, s);
+      for (let x = -r; x <= r; x += 4) {
+        const y = waterY + Math.sin((x / r) * Math.PI * 2 + this.phase + k * Math.PI) * amp;
+        ctx.lineTo(cx + x, y);
+      }
+      ctx.lineTo(cx + r, s); ctx.closePath();
+      ctx.fillStyle = k === 0 ? this.color : this.color + '99';
+      ctx.fill();
+    }
+    ctx.restore();
+    // pct text
+    ctx.fillStyle = '#e6edf3'; ctx.font = '700 26px -apple-system,sans-serif';
+    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
+    ctx.fillText(this.level.toFixed(this.target < 10 ? 1 : 0) + '%', cx, cy);
+    requestAnimationFrame(() => this.draw());
+  }
+}
+
+const COLORS = { usre: '#3fb950', CRCP: '#58a6ff' };
+const gauges = {};
+let built = false;
+
+function build(metrics) {
+  const groups = {};
+  metrics.forEach(m => { (groups[m.group] = groups[m.group] || []).push(m); });
+  const host = document.getElementById('groups'); host.innerHTML = '';
+  for (const [g, ms] of Object.entries(groups)) {
+    const sec = document.createElement('div'); sec.className = 'grp';
+    sec.innerHTML = `<h2>${g}</h2><div class="grid"></div>`;
+    const grid = sec.querySelector('.grid');
+    ms.forEach(m => {
+      const card = document.createElement('div'); card.className = 'card';
+      card.innerHTML = `<canvas id="g_${m.key}"></canvas><div class="lab">${m.label}</div><div class="cnt" id="c_${m.key}"></div>`;
+      grid.appendChild(card);
+    });
+    host.appendChild(sec);
+    ms.forEach(m => { gauges[m.key] = new LiquidGauge(document.getElementById('g_' + m.key), COLORS[g] || '#3fb950'); });
+  }
+  built = true;
+}
+
+async function refresh() {
+  const d = await fetch('/api/coverage').then(r => r.json()).catch(() => null);
+  if (!d) return;
+  if (!built) build(d.metrics);
+  d.metrics.forEach(m => {
+    gauges[m.key] && gauges[m.key].setTarget(m.pct);
+    const c = document.getElementById('c_' + m.key);
+    if (c) c.textContent = `${(m.num || 0).toLocaleString()} / ${(m.den || 0).toLocaleString()} ${m.unit}`;
+  });
+  const t = d.totals;
+  document.getElementById('totals').innerHTML = [
+    ['Sites found', t.sites_found], ['Phones found', t.phones_found],
+    ['Agents searched', t.searched], ['Firm-linked', t.firm_linked],
+  ].map(([l, v]) => `<div class="tot"><b>${(v || 0).toLocaleString()}</b><span class="l">${l}</span></div>`).join('');
+  document.getElementById('upd').textContent = 'updated ' + new Date(d.updated).toLocaleTimeString();
+  const w = await fetch('/api/wins').then(r => r.json()).catch(() => ({ wins: [] }));
+  const wl = document.getElementById('winlist');
+  if (w.wins && w.wins.length) wl.innerHTML = w.wins.map(x =>
+    `<div class="win"><span>🏆</span><span class="t">${x.title}</span><span class="when">${new Date(x.ts).toLocaleString()}</span></div>`).join('');
+}
+refresh(); setInterval(refresh, 15000);
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100755
index 0000000..6e722e2
--- /dev/null
+++ b/server.js
@@ -0,0 +1,105 @@
+#!/usr/bin/env node
+/**
+ * RE Contact-Coverage dashboard — liquid-fill gauges showing how the $0/local
+ * agent contact-discovery is filling in phone / firm / site / agent-site coverage
+ * across the RE builds (usre + CRCP), plus a wins/milestone feed.
+ *
+ * Zero deps: shells to psql for usre counts, reads CRCP data/agent-sites.json.
+ * Read-only. Milestones appended to data/milestones.jsonl as coverage crosses
+ * thresholds (the "track wins" ask); big ones also POST to CNCP.
+ */
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const { execSync } = require('child_process');
+
+const PORT = process.env.PORT || 9792;
+const ROOT = __dirname;
+const CRCP_SITES = process.env.HOME + '/Projects/commercialrealestate/data/agent-sites.json';
+const MILESTONES = path.join(ROOT, 'data', 'milestones.jsonl');
+fs.mkdirSync(path.join(ROOT, 'data'), { recursive: true });
+
+function psql1(db, sql) {
+  try { return execSync(`psql -h /tmp ${db} -tAc ${JSON.stringify(sql)}`, { encoding: 'utf8', timeout: 8000 }).trim(); }
+  catch { return ''; }
+}
+const nums = s => s.split('|').map(x => parseInt(x, 10) || 0);
+
+function coverage() {
+  // usre broker + firm
+  const b = nums(psql1('usre', `SELECT count(*), count(*) FILTER (WHERE firm_id IS NOT NULL), count(website_status), count(website) FROM broker`));
+  const f = nums(psql1('usre', `SELECT count(*), (SELECT count(url) FROM firm_site) FROM firm`));
+  const [bTotal, bFirm, bAttempted, bSite] = b.length === 4 ? b : [0, 0, 0, 0];
+  const [fTotal, fSite] = f.length === 2 ? f : [0, 0];
+  // CRCP agent-sites
+  let cAttempted = 0, cFound = 0, cPhone = 0, cTotal = 2022;
+  try {
+    const s = JSON.parse(fs.readFileSync(CRCP_SITES, 'utf8'));
+    const v = Object.values(s);
+    cAttempted = v.length; cFound = v.filter(x => x.status === 'found').length; cPhone = v.filter(x => x.phone).length;
+  } catch {}
+
+  const pct = (n, d) => d > 0 ? Math.round((n / d) * 1000) / 10 : 0;
+  const metrics = [
+    { key: 'usre_firm', group: 'usre', label: 'Agents tied to a firm', pct: pct(bFirm, bTotal), num: bFirm, den: bTotal, unit: 'agents' },
+    { key: 'usre_firmsite', group: 'usre', label: 'Firms with a website', pct: pct(fSite, fTotal), num: fSite, den: fTotal, unit: 'firms' },
+    { key: 'usre_swept', group: 'usre', label: 'Agent sweep progress', pct: pct(bAttempted, bTotal), num: bAttempted, den: bTotal, unit: 'agents searched' },
+    { key: 'usre_agentsite', group: 'usre', label: 'Agent own-sites found', pct: pct(bSite, Math.max(bAttempted, 1)), num: bSite, den: bAttempted, unit: 'of searched' },
+    { key: 'crcp_swept', group: 'CRCP', label: 'CRCP agents searched', pct: pct(cAttempted, cTotal), num: cAttempted, den: cTotal, unit: 'agents' },
+    { key: 'crcp_site', group: 'CRCP', label: 'CRCP agent sites found', pct: pct(cFound, Math.max(cAttempted, 1)), num: cFound, den: cAttempted, unit: 'of searched' },
+    { key: 'crcp_phone', group: 'CRCP', label: 'CRCP phones found', pct: pct(cPhone, Math.max(cAttempted, 1)), num: cPhone, den: cAttempted, unit: 'of searched' },
+  ];
+  const totals = {
+    sites_found: bSite + cFound,
+    phones_found: cPhone,
+    searched: bAttempted + cAttempted,
+    firm_linked: bFirm,
+  };
+  checkMilestones(totals);
+  return { updated: new Date().toISOString(), metrics, totals };
+}
+
+// "track wins": append a milestone when a running total crosses a threshold.
+function readMilestones() {
+  try { return fs.readFileSync(MILESTONES, 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l)); }
+  catch { return []; }
+}
+function checkMilestones(t) {
+  const done = new Set(readMilestones().map(m => m.id));
+  const bands = { sites_found: 10, phones_found: 5, searched: 100 };
+  const labels = { sites_found: 'agent/firm sites found', phones_found: 'phone numbers found', searched: 'agents searched' };
+  const out = [];
+  for (const [metric, step] of Object.entries(bands)) {
+    const crossed = Math.floor((t[metric] || 0) / step) * step;
+    if (crossed >= step) {
+      const id = `${metric}:${crossed}`;
+      if (!done.has(id)) out.push({ id, ts: new Date().toISOString(), metric, value: crossed, title: `${crossed.toLocaleString()} ${labels[metric]}` });
+    }
+  }
+  if (out.length) {
+    fs.appendFileSync(MILESTONES, out.map(m => JSON.stringify(m)).join('\n') + '\n');
+    // big round milestones also land as a CNCP win (best-effort, non-blocking)
+    for (const m of out) if (m.value >= 50) postWin(m).catch(() => {});
+  }
+}
+function postWin(m) {
+  const body = JSON.stringify({ project: 're-coverage', title: `RE contact coverage: ${m.title}`, summary: `$0/local discovery crossed ${m.title} across usre + CRCP.` });
+  return new Promise((res, rej) => {
+    const req = http.request('http://127.0.0.1:3333/api/wins', { method: 'POST', headers: { 'Content-Type': 'application/json' } }, r => { r.resume(); r.on('end', res); });
+    req.on('error', rej); req.write(body); req.end();
+  });
+}
+
+const server = http.createServer((req, res) => {
+  if (req.url === '/healthz') { res.end('ok'); return; }
+  if (req.url === '/api/coverage') { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(coverage())); return; }
+  if (req.url === '/api/wins') { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ wins: readMilestones().reverse() })); return; }
+  const file = req.url === '/' ? '/index.html' : req.url.split('?')[0];
+  const fp = path.join(ROOT, 'public', file);
+  if (fp.startsWith(path.join(ROOT, 'public')) && fs.existsSync(fp) && fs.statSync(fp).isFile()) {
+    res.setHeader('Content-Type', file.endsWith('.html') ? 'text/html' : 'text/plain');
+    res.end(fs.readFileSync(fp)); return;
+  }
+  res.statusCode = 404; res.end('not found');
+});
+server.listen(PORT, () => console.log(`RE coverage dashboard → http://127.0.0.1:${PORT}`));

(oldest)  ·  back to Re Coverage Dashboard  ·  add launchd plists (dashboard + filler) for reboot-durabilit c3a3a70 →