[object Object]

← back to Commercialrealestate

CRCP: broker-level rollup view — /api/brokers/licensed + Agents⇄Brokers toggle (131 brokers from DRE responsible-broker field, $0)

93c26429823b9b30d69323940ad518c269409307 · 2026-07-10 22:54:52 -0700 · Steve Abrams

Files touched

Diff

commit 93c26429823b9b30d69323940ad518c269409307
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jul 10 22:54:52 2026 -0700

    CRCP: broker-level rollup view — /api/brokers/licensed + Agents⇄Brokers toggle (131 brokers from DRE responsible-broker field, $0)
---
 package-lock.json           |  4 +--
 package.json                |  2 +-
 public/licensed-agents.html | 62 ++++++++++++++++++++++++++++++++++++++++++---
 scripts/serve.js            | 42 ++++++++++++++++++++++++++++++
 4 files changed, 104 insertions(+), 6 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index eeb8212..9164f7f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "commercialrealestate",
-  "version": "0.5.0",
+  "version": "0.6.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "commercialrealestate",
-      "version": "0.5.0",
+      "version": "0.6.0",
       "dependencies": {
         "better-sqlite3": "^12.11.1",
         "express": "^4.22.2",
diff --git a/package.json b/package.json
index ccfd8b7..64644f4 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "commercialrealestate",
-  "version": "0.5.0",
+  "version": "0.6.0",
   "private": true,
   "description": "LA County CRE investment explorer — multi-firm sourced, Census + assessor enriched, Qwen-analyzed",
   "scripts": {
diff --git a/public/licensed-agents.html b/public/licensed-agents.html
index 299f5e3..493beee 100644
--- a/public/licensed-agents.html
+++ b/public/licensed-agents.html
@@ -34,12 +34,21 @@
   .kv .v a{color:var(--blue);text-decoration:none;} .kv .v a:hover{text-decoration:underline;}
   .conf{font-size:10px;color:var(--mut);margin-top:7px;}
   .verified{color:var(--acc);font-weight:700;}
+  .modes{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden;}
+  .modes button{background:var(--card);color:var(--mut);border:0;padding:6px 12px;font-size:12px;cursor:pointer;}
+  .modes button.on{background:var(--blue);color:#0e1116;font-weight:700;}
+  .bstat{display:flex;gap:8px;margin-top:10px;font-size:11px;}
+  .bstat span{background:rgba(139,148,158,.1);border:1px solid var(--line);border-radius:10px;padding:2px 8px;}
+  .bstat .sv{color:var(--acc);} .bstat .sp{color:var(--warn);} .bstat .su{color:var(--mut);}
+  .agnum{font-size:26px;font-weight:800;line-height:1;font-variant-numeric:tabular-nums;}
+  .agnum small{font-size:11px;font-weight:500;color:var(--mut);}
 </style>
 </head>
 <body>
 <header>
   <h1>🎫 Licensed <b>Agents &amp; Brokers</b></h1>
   <div class="nav"><a href="/mls.html">← MLS</a><a href="/">Explorer</a><a href="/broker-grid.html">Broker Grid</a><a href="/crcp.html">🛰️ CRCP</a></div>
+  <div class="modes" id="modes"><button class="mode on" data-m="agents">👤 Agents</button><button class="mode" data-m="brokers">🏢 Brokers</button></div>
   <input class="search" id="q" type="text" placeholder="search name, brokerage, license #, responsible broker…">
   <select id="sortsel" title="Sort">
     <option value="name">Sort: Name A→Z</option>
@@ -139,14 +148,61 @@ async function load(){
     (c.verified!=null?`— <span class="verified">${c.verified} verified</span>, ${c.probable||0} probable, ${c.unmatched||0} unmatched, ${c.errors||0} errors. ${esc(META.cost||'')}`:'');
   pills(); render();
 }
+// ── Brokers view (rolled up from /api/brokers/licensed — the "broker database") ──
+let mode='agents', BROKERS=[], bLoaded=false, bSort='agents';
+const AGENT_SORTS=[['name','Name A→Z'],['status','Status'],['type','License type'],['exp','Expiration (soonest)'],['brokerage','Brokerage'],['conf','Match confidence']];
+const BROKER_SORTS=[['agents','Agent count (most)'],['verified','Verified (most)'],['name','Broker A→Z']];
+function fillSort(opts,cur){ $('#sortsel').innerHTML=opts.map(([v,l])=>`<option value="${v}">Sort: ${l}</option>`).join(''); $('#sortsel').value=cur; }
+async function loadBrokers(){
+  if(bLoaded) return;
+  let d; try{ d=await (await fetch('/api/brokers/licensed')).json(); }catch(e){ $('#note').textContent='Failed to load /api/brokers/licensed'; return; }
+  BROKERS=d.brokers||[]; bLoaded=true;
+  const m=d.meta||{};
+  $('#note').innerHTML=`Source: <b>${esc(d.source||'?')}</b> · ${esc(d.label||'')} — <b>${m.broker_count||BROKERS.length}</b> distinct brokers ${esc(m.cost||'')}`;
+}
+function brokerCard(b){
+  const total=b.agent_count||0;
+  return `<div class="card">
+    <div class="top">
+      <div><div class="nm">${esc(b.broker||'—')}</div>
+        <div class="bk">${b.broker_license?('DRE #'+esc(b.broker_license)):'—'}${b.top_city?(' · '+esc(b.top_city)):''}</div></div>
+      <span class="agnum">${total}<br><small>agents</small></span>
+    </div>
+    <div class="bstat">
+      <span class="sv">✔ ${b.verified||0} verified</span>
+      <span class="sp">~ ${b.probable||0} probable</span>
+      <span class="su">? ${b.unmatched||0} unmatched</span>
+    </div>
+  </div>`;
+}
+function renderBrokers(){
+  let rows=BROKERS.filter(b=>{ if(!q) return true; return (b.broker+' '+(b.broker_license||'')+' '+(b.top_city||'')).toLowerCase().includes(q); });
+  const cmp={ agents:(a,b)=>b.agent_count-a.agent_count||a.broker.localeCompare(b.broker),
+    verified:(a,b)=>b.verified-a.verified||b.agent_count-a.agent_count,
+    name:(a,b)=>a.broker.localeCompare(b.broker) }[bSort];
+  rows.sort(cmp);
+  $('#grid').innerHTML=rows.map(brokerCard).join('')||'<div class="note">No brokers match.</div>';
+  $('#count').textContent=rows.length+' / '+BROKERS.length+' brokers';
+}
+function draw(){ mode==='brokers'?renderBrokers():render(); }
+async function setMode(m){
+  mode=m; localStorage.setItem('laMode',m);
+  document.querySelectorAll('.mode').forEach(x=>x.classList.toggle('on',x.dataset.m===m));
+  $('#pills').style.display=m==='brokers'?'none':'';
+  if(m==='brokers'){ await loadBrokers(); fillSort(BROKER_SORTS,bSort); }
+  else { fillSort(AGENT_SORTS,sortKey); load(); }
+  draw();
+}
+document.querySelectorAll('.mode').forEach(x=>x.onclick=()=>setMode(x.dataset.m));
+
 // controls
-$('#q').oninput=e=>{q=e.target.value.toLowerCase().trim();render();};
-$('#sortsel').onchange=e=>{sortKey=e.target.value;localStorage.setItem('laSort',sortKey);render();};
+$('#q').oninput=e=>{q=e.target.value.toLowerCase().trim();draw();};
+$('#sortsel').onchange=e=>{ if(mode==='brokers'){bSort=e.target.value;} else {sortKey=e.target.value;localStorage.setItem('laSort',sortKey);} draw();};
 $('#density').oninput=e=>{document.documentElement.style.setProperty('--cw',e.target.value+'px');localStorage.setItem('laDens',e.target.value);};
 // restore persisted
 (()=>{const s=localStorage.getItem('laSort'); if(s){sortKey=s;$('#sortsel').value=s;}
   const dv=localStorage.getItem('laDens')||'320'; $('#density').value=dv; document.documentElement.style.setProperty('--cw',dv+'px');})();
-load();
+if(localStorage.getItem('laMode')==='brokers') setMode('brokers'); else load();
 </script>
 </body>
 </html>
diff --git a/scripts/serve.js b/scripts/serve.js
index 6f20099..7bdf59a 100644
--- a/scripts/serve.js
+++ b/scripts/serve.js
@@ -247,6 +247,48 @@ app.get('/api/agents/licensed', (req, res) => {
   }
 });
 
+// Broker-level rollup of the SAME snapshot: group the licensed agents by their DRE
+// responsible broker. This is the "broker database" half of the original ask — a pure
+// in-memory aggregation over data we already captured (no new govt calls, $0). Keyed by
+// responsible_broker_id when present (a real DRE license id), else the normalized broker
+// name; each broker carries its supervised-agent count + license-verification breakdown.
+const BROKERS_LABEL = 'Brokers derived from the CA DRE responsible-broker field of the licensed-agent set — supervising broker + how many verified/probable/unmatched agents report to each ($0, govt data).';
+// The DRE responsible_broker field is "NAME <street#> ADDRESS CITY, CA <zip>". Cut from the
+// first street-number run through the trailing state — CA addresses reliably end in "…, CA".
+// This is robust to numbered streets ("8TH ST") and directionals ("W GLENOAKS") that a
+// caps-only pattern misses, while names without a ", CA" address (e.g. "Century 21 Peak") stay whole.
+const cleanBrokerName = s => String(s || '').replace(/\s{2,}.*$/, '').replace(/\s\d+\s.*,\s*CA\b.*$/, '').trim();
+app.get('/api/brokers/licensed', (req, res) => {
+  try {
+    const { meta = {}, agents = [] } = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'licensed-agents.json'), 'utf8'));
+    const groups = new Map();
+    for (const a of agents) {
+      const raw = a.responsible_broker;
+      if (!raw || !raw.trim()) continue;
+      const key = (a.responsible_broker_id || cleanBrokerName(raw)).toUpperCase();
+      if (!groups.has(key)) {
+        groups.set(key, { broker: cleanBrokerName(raw), broker_license: a.responsible_broker_id || null,
+          agent_count: 0, verified: 0, probable: 0, unmatched: 0, cities: {} });
+      }
+      const g = groups.get(key);
+      g.agent_count++;
+      if (a.dre_verified) g.verified++;
+      else if (a.dre_match === 'probable') g.probable++;
+      else g.unmatched++;
+      const city = (a.license_city || '').trim();
+      if (city) g.cities[city] = (g.cities[city] || 0) + 1;
+    }
+    const brokers = [...groups.values()].map(g => ({
+      broker: g.broker, broker_license: g.broker_license, agent_count: g.agent_count,
+      verified: g.verified, probable: g.probable, unmatched: g.unmatched,
+      top_city: Object.entries(g.cities).sort((a, b) => b[1] - a[1])[0]?.[0] || null,
+    })).sort((a, b) => b.agent_count - a.agent_count);
+    return res.json({ brokers, meta: { ...meta, broker_count: brokers.length }, label: BROKERS_LABEL, source: 'snapshot' });
+  } catch (e) {
+    return res.json({ brokers: [], note: 'no licensed-agent data yet — run scripts/fetch-dre-licenses.js', error: String(e.message).split('\n')[0] });
+  }
+});
+
 // Property history for ONE address — last ~100 yrs, ADDRESS-ONLY (no owner/resident names, by design).
 // Combines: the public assessor parcel roll (year built, beds/baths/sqft, use, valuation — current roll),
 // sold transactions from closed_sale (Redfin sold history), and the WhoLivedThere/pastdoor archive

← aa5fee6 CRCP: full 556-agent DRE license snapshot (162 verified / 26  ·  back to Commercialrealestate  ·  CRCP: contrarian fixes on broker rollup — disclose 123 broke 71cc617 →