← 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
M package-lock.jsonM package.jsonM public/licensed-agents.htmlM scripts/serve.js
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 & 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 →