← back to Commercialrealestate
CRCP: building prices on Call Segments (med + p10–p90 band) and per-agent avg/top price in call list; chip density slider + collapse-all-until-activated panels (persisted)
b0cabb2d61f95e7ab2d3ca033e50f45034297abe · 2026-06-28 17:15:20 -0700 · steve@designerwallcoverings.com
Files touched
M public/crcp.htmlM scripts/serve.js
Diff
commit b0cabb2d61f95e7ab2d3ca033e50f45034297abe
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date: Sun Jun 28 17:15:20 2026 -0700
CRCP: building prices on Call Segments (med + p10–p90 band) and per-agent avg/top price in call list; chip density slider + collapse-all-until-activated panels (persisted)
---
public/crcp.html | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++-----
scripts/serve.js | 47 +++++++++++++++++++++++++++++++---------
2 files changed, 98 insertions(+), 15 deletions(-)
diff --git a/public/crcp.html b/public/crcp.html
index b7d8f42..a1ba5cb 100644
--- a/public/crcp.html
+++ b/public/crcp.html
@@ -16,7 +16,15 @@
@keyframes p{0%{box-shadow:0 0 0 0 rgba(112,173,71,.6)}70%{box-shadow:0 0 0 10px rgba(112,173,71,0)}100%{box-shadow:0 0 0 0 rgba(112,173,71,0)}}
header .nav{margin-left:auto;display:flex;gap:14px;} header .nav a{color:var(--draft);text-decoration:none;font-size:13px;} header .nav a:hover{color:var(--gold);}
.wrap{padding:18px 22px 40px;max-width:1320px;margin:0 auto;}
- .tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin-bottom:18px;}
+ .tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(var(--tilemin,150px),1fr));gap:14px;margin-bottom:18px;}
+ /* collapse-all-until-activated */
+ .panel h3{cursor:pointer;user-select:none;}
+ .panel h3 .caret{color:var(--gold);margin-right:6px;display:inline-block;transition:transform .15s;}
+ .panel.collapsed h3 .caret{transform:rotate(-90deg);}
+ .panel.collapsed > *:not(h3){display:none;}
+ .denslider{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--muted);}
+ .denslider input{width:90px;accent-color:var(--gold);}
+ .expandall{background:var(--panel2);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:5px 10px;font-size:12px;cursor:pointer;}
.tile{background:var(--panel);border:1px solid var(--line);border-radius:13px;padding:15px 16px;position:relative;overflow:hidden;}
.tile .k{font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);}
.tile .v{font-size:30px;font-weight:700;font-variant-numeric:tabular-nums;margin-top:3px;}
@@ -27,8 +35,16 @@
.cols{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
@media(max-width:880px){.cols{grid-template-columns:1fr;}}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:13px;padding:16px;margin-bottom:16px;}
- .panel h3{margin:0 0 12px;font-size:13px;text-transform:uppercase;letter-spacing:.6px;color:var(--gold);display:flex;justify-content:space-between;}
+ .panel h3{margin:0 0 12px;font-size:13px;text-transform:uppercase;letter-spacing:.6px;color:var(--gold);display:flex;justify-content:space-between;cursor:pointer;user-select:none;}
.panel h3 .c{color:var(--muted);font-weight:400;}
+ .panel h3::before{content:'▸ ';color:var(--muted);transition:transform .15s;}
+ .panel:not(.collapsed) h3::before{content:'▾ ';}
+ .panel.collapsed > :not(h3){display:none !important;}
+ .panel.collapsed h3{margin:0;}
+ /* density slider for the tiles/chips grid */
+ .tiles{grid-template-columns:repeat(auto-fit,minmax(var(--tilemin,150px),1fr));}
+ .dens{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted);}
+ .dens input{width:90px;accent-color:var(--gold);}
table{width:100%;border-collapse:collapse;font-size:13px;} td,th{text-align:left;padding:6px 6px;border-top:1px solid var(--line);}
th{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.4px;border-top:0;}
th.srt{cursor:pointer;user-select:none;} th.srt:hover{color:var(--fg);}
@@ -67,12 +83,14 @@
<span class="live"><span class="pulse"></span><span id="livetxt">live</span></span>
<nav class="nav">
<a href="/">Properties</a><a href="/brokers.html">Mind-map</a><a href="/graphics.html">Graphics</a><a href="/condos.html">Condos</a>
+ <span class="denslider" title="chip / tile density">⊞ <input type="range" id="dens" min="120" max="300" value="150"></span>
+ <button class="expandall" id="expandall">Expand all ▾</button>
</nav>
</header>
<div class="wrap">
<div class="tiles" id="tiles"></div>
<div class="panel"><h3>📞 Call Segments — your prospects by Arcstone loan product <span class="c">click a segment for the call list</span></h3>
- <table id="segTbl"><thead><tr><th>Segment</th><th>Arcstone product / hook</th><th class="n">Properties</th><th class="n">Agents to call</th></tr></thead><tbody></tbody></table>
+ <table id="segTbl"><thead><tr><th>Segment</th><th>Arcstone product / hook</th><th class="n">Properties</th><th class="n">Price range (med)</th><th class="n">Agents to call</th></tr></thead><tbody></tbody></table>
</div>
<div class="cols">
<div>
@@ -151,9 +169,13 @@ const LEAD_SOURCES=[
function leadSourcesHtml(){ return `<div class="sec"><h4>Lead sources — finding self-employed buyers (compliant)</h4>${LEAD_SOURCES.map(([t,d])=>`<div style="margin-bottom:8px"><b style="font-size:13px">${t}</b><div class="meta" style="margin-top:2px">${d}</div></div>`).join('')}</div>`; }
async function loadSegments(){
let d; try{ d=await (await fetch('/api/segments')).json(); }catch(e){ return; }
+ const kM=n=>n==null?null:(n>=1e6?'$'+(n/1e6).toFixed(n>=1e7?0:1)+'M':'$'+Math.round(n/1e3)+'k');
+ const priceCell=g=>{ if(g.priceMed==null) return '<span style="color:var(--muted)">—</span>';
+ const range=(g.priceMin!=null&&g.priceMax!=null&&g.priceMax>g.priceMin)?`<div class="meta" style="margin:0">${kM(g.priceMin)}–${kM(g.priceMax)}</div>`:'';
+ return `<b style="color:var(--gold)">${kM(g.priceMed)}</b>${range}`; };
$('#segTbl tbody').innerHTML=(d.segments||[]).map(g=>`<tr class="srow" data-key="${g.key}" style="cursor:${g.props?'pointer':'default'}${g.props?'':';opacity:.5'}">
<td><b>${g.label}</b></td><td style="color:var(--muted)">${g.product}</td>
- <td class="n">${(g.props||0).toLocaleString()}</td><td class="n" style="color:var(--draft)">${g.agents!=null?g.agents.toLocaleString():'—'}</td></tr>`).join('');
+ <td class="n">${(g.props||0).toLocaleString()}</td><td class="n">${priceCell(g)}</td><td class="n" style="color:var(--draft)">${g.agents!=null?g.agents.toLocaleString():'—'}</td></tr>`).join('');
document.querySelectorAll('.srow').forEach(r=>r.onclick=()=>openSegment(r.dataset.key));
}
async function openSegment(key){
@@ -164,7 +186,7 @@ async function openSegment(key){
<div class="meta" style="margin:8px 0;border-left:2px solid var(--gold);padding-left:8px">${d.hook}</div>
<div class="sec"><h4>Agents to call (${d.callList.length}) — click one for full contact + a tailored ${d.label} pitch</h4>
${d.callList.length?d.callList.map(c=>`<div class="litem brow3" data-id="${c.id}" data-name="${(c.name||'').replace(/"/g,'"')}" style="cursor:pointer">
- <span class="a">${c.name} ${atype(c.agent_type)}</span>
+ <span class="a">${c.name} ${atype(c.agent_type)}${c.avg_price?`<span class="meta" style="margin:0;display:block">🏢 ${money(c.avg_price)} avg${c.top_price&&c.top_price>c.avg_price?` · up to ${money(c.top_price)}`:''}</span>`:''}</span>
<span class="m">${c.n} props · ${c.phone?`<a href="tel:${c.phone}" class="has" style="color:var(--active)">${c.phone}</a>`:'no phone'} ${c.email?'✉':''}</span></div>`).join(''):'<div class="meta">'+(d.note||'no matches')+'</div>'}</div>
${(key==='sfr-bankstmt'||key==='nonqm')?leadSourcesHtml():''}`;
document.querySelectorAll('.brow3').forEach(r=>r.onclick=e=>{ if(e.target.tagName==='A') return; openContact(r.dataset.name, r.dataset.id, key); });
@@ -241,6 +263,40 @@ async function poll(){
document.querySelectorAll('.delta.show').forEach(d=>d.closest('.tile').classList.add('flash'));
setTimeout(()=>document.querySelectorAll('.flash').forEach(e=>e.classList.remove('flash')),800);
}
+// ---- collapse-all-until-activated + density slider (persisted) ----
+(function initPanels(){
+ const LS_OPEN='crcp_open_panels', LS_DENS='crcp_dens';
+ // restore density
+ const dens=$('#dens'), saved=+localStorage.getItem(LS_DENS)||150;
+ dens.value=saved; document.documentElement.style.setProperty('--tilemin', saved+'px');
+ dens.addEventListener('input',()=>{ document.documentElement.style.setProperty('--tilemin', dens.value+'px'); localStorage.setItem(LS_DENS, dens.value); });
+ // which panels were left open last time (default: ALL collapsed)
+ let open; try{ open=new Set(JSON.parse(localStorage.getItem(LS_OPEN)||'[]')); }catch(e){ open=new Set(); }
+ const save=()=>localStorage.setItem(LS_OPEN, JSON.stringify([...open]));
+ const panels=[...document.querySelectorAll('.panel')];
+ panels.forEach((p,i)=>{
+ const h=p.querySelector('h3'); if(!h) return;
+ const key=h.textContent.trim().slice(0,40)||('p'+i); p.dataset.pk=key;
+ if(!h.querySelector('.caret')){ const c=document.createElement('span'); c.className='caret'; c.textContent='▸'; h.prepend(c); }
+ if(!open.has(key)) p.classList.add('collapsed'); // start collapsed until activated
+ h.addEventListener('click',()=>{
+ p.classList.toggle('collapsed');
+ if(p.classList.contains('collapsed')) open.delete(key); else open.add(key);
+ save(); syncExpandBtn();
+ });
+ });
+ const btn=$('#expandall');
+ function syncExpandBtn(){ const allOpen=panels.every(p=>!p.classList.contains('collapsed'));
+ btn.textContent = allOpen?'Collapse all ▴':'Expand all ▾'; }
+ btn.addEventListener('click',()=>{
+ const allOpen=panels.every(p=>!p.classList.contains('collapsed'));
+ panels.forEach(p=>{ const key=p.dataset.pk;
+ if(allOpen){ p.classList.add('collapsed'); open.delete(key); }
+ else { p.classList.remove('collapsed'); open.add(key); } });
+ save(); syncExpandBtn();
+ });
+ syncExpandBtn();
+})();
poll(); setInterval(poll, 3000);
loadSegments(); setInterval(loadSegments, 20000);
</script>
diff --git a/scripts/serve.js b/scripts/serve.js
index 2f0d12c..2dd8897 100644
--- a/scripts/serve.js
+++ b/scripts/serve.js
@@ -438,38 +438,49 @@ const SEGMENTS = {
'nonqm': { label: 'Non-QM (all)', product: 'Non-QM umbrella', hook: 'any non-QM scenario — non-warrantable condo, DSCR, bank-statement, asset-based', src: 'nonqm', where: null }
};
function segCallList(seg) {
+ // every branch returns avg_price + top_price so the call list shows the BUILDING PRICES
+ // each agent carries (NULLIF avoids /0; prices are the listed/condo/sfr asking prices).
if (seg.src === 'listing') return {
- sql: `SELECT b.id, b.name, b.agent_type, b.phone, b.email, count(*)::int n, min(l.address) sample
+ sql: `SELECT b.id, b.name, b.agent_type, b.phone, b.email, count(*)::int n, min(l.address) sample,
+ round(avg(NULLIF(l.price,0)))::bigint avg_price, max(l.price)::bigint top_price
FROM listing l JOIN broker_listing bl ON bl.listing_id=l.id JOIN broker b ON b.id=bl.broker_id
WHERE ${seg.where} GROUP BY b.id ORDER BY n DESC, b.name LIMIT 300`, args: [] };
if (seg.src === 'condo') return {
- sql: `SELECT b.id, b.name, b.agent_type, b.phone, b.email, count(*)::int n, min(c.address) sample
+ sql: `SELECT b.id, b.name, b.agent_type, b.phone, b.email, count(*)::int n, min(c.address) sample,
+ round(avg(NULLIF(c.price,0)))::bigint avg_price, max(c.price)::bigint top_price
FROM condo c JOIN broker_condo bc ON bc.condo_id=c.id JOIN broker b ON b.id=bc.broker_id
WHERE ${seg.where} GROUP BY b.id ORDER BY n DESC, b.name LIMIT 300`, args: [] };
if (seg.src === 'nonqm') return {
- sql: `SELECT id, name, agent_type, phone, email, sum(n)::int n, min(sample) sample FROM (
- SELECT b.id, b.name, b.agent_type, b.phone, b.email, count(*) n, min(c.address) sample
+ sql: `SELECT id, name, agent_type, phone, email, sum(n)::int n, min(sample) sample,
+ round(avg(NULLIF(avg_price,0)))::bigint avg_price, max(top_price)::bigint top_price FROM (
+ SELECT b.id, b.name, b.agent_type, b.phone, b.email, count(*) n, min(c.address) sample,
+ avg(NULLIF(c.price,0)) avg_price, max(c.price) top_price
FROM condo c JOIN broker_condo bc ON bc.condo_id=c.id JOIN broker b ON b.id=bc.broker_id
WHERE c.warrantable_status <> 'fha_approved' GROUP BY b.id
UNION ALL
- SELECT b.id, b.name, b.agent_type, b.phone, b.email, count(*) n, min(l.address) sample
+ SELECT b.id, b.name, b.agent_type, b.phone, b.email, count(*) n, min(l.address) sample,
+ avg(NULLIF(l.price,0)) avg_price, max(l.price) top_price
FROM listing l JOIN broker_listing bl ON bl.listing_id=l.id JOIN broker b ON b.id=bl.broker_id
WHERE l.units BETWEEN 1 AND 9 OR l.type ILIKE '%mixed%' GROUP BY b.id
) u GROUP BY id, name, agent_type, phone, email ORDER BY n DESC LIMIT 300`, args: [] };
if (seg.src === 'sfr') return {
- sql: `SELECT b.id, b.name, b.agent_type, b.phone, b.email, count(*)::int n, min(s.address) sample
+ sql: `SELECT b.id, b.name, b.agent_type, b.phone, b.email, count(*)::int n, min(s.address) sample,
+ round(avg(NULLIF(s.price,0)))::bigint avg_price, max(s.price)::bigint top_price
FROM sfr s JOIN broker_sfr bs ON bs.sfr_id=s.id JOIN broker b ON b.id=bs.broker_id
WHERE ${seg.where} GROUP BY b.id ORDER BY n DESC, b.name LIMIT 300`, args: [] };
if (seg.src === 'assumable') return {
// assumable estimate -> property -> listing-agent edge (condo OR sfr). Union both edges so a
// likely-assumable condo OR sfr surfaces its listing agent. Estimate is heuristic (see hook).
- sql: `SELECT id, name, agent_type, phone, email, sum(n)::int n, min(sample) sample FROM (
- SELECT b.id, b.name, b.agent_type, b.phone, b.email, count(*) n, min(c.address) sample
+ sql: `SELECT id, name, agent_type, phone, email, sum(n)::int n, min(sample) sample,
+ round(avg(NULLIF(avg_price,0)))::bigint avg_price, max(top_price)::bigint top_price FROM (
+ SELECT b.id, b.name, b.agent_type, b.phone, b.email, count(*) n, min(c.address) sample,
+ avg(NULLIF(c.price,0)) avg_price, max(c.price) top_price
FROM assumable_estimate a JOIN condo c ON c.id=a.property_id AND a.src='condo'
JOIN broker_condo bc ON bc.condo_id=c.id JOIN broker b ON b.id=bc.broker_id
WHERE ${seg.where} GROUP BY b.id
UNION ALL
- SELECT b.id, b.name, b.agent_type, b.phone, b.email, count(*) n, min(s.address) sample
+ SELECT b.id, b.name, b.agent_type, b.phone, b.email, count(*) n, min(s.address) sample,
+ avg(NULLIF(s.price,0)) avg_price, max(s.price) top_price
FROM assumable_estimate a JOIN sfr s ON s.id=a.property_id AND a.src='sfr'
JOIN broker_sfr bs ON bs.sfr_id=s.id JOIN broker b ON b.id=bs.broker_id
WHERE ${seg.where} GROUP BY b.id
@@ -481,7 +492,22 @@ app.get('/api/segments', async (req, res) => {
try {
const out = [];
for (const [key, seg] of Object.entries(SEGMENTS)) {
- let props = 0, agents = 0;
+ let props = 0, agents = 0, priceMed = null, priceMin = null, priceMax = null;
+ // price band uses p10–p50–p90 so a single outlier ($400M typo) doesn't blow out the range.
+ const PCT = p => `percentile_disc(${p}) WITHIN GROUP (ORDER BY price)::bigint`;
+ const tbl = seg.src === 'listing' ? 'listing' : seg.src === 'condo' ? 'condo' : seg.src === 'sfr' ? 'sfr' : null;
+ let priceFrom = tbl ? `(SELECT price FROM ${tbl} WHERE (${seg.where}) AND price>0)` : null;
+ if (seg.src === 'nonqm') priceFrom =
+ `(SELECT price FROM condo WHERE warrantable_status<>'fha_approved' AND price>0
+ UNION ALL SELECT price FROM listing WHERE (units BETWEEN 1 AND 9 OR type ILIKE '%mixed%') AND price>0)`;
+ if (seg.src === 'assumable') priceFrom =
+ `(SELECT c.price FROM assumable_estimate a JOIN condo c ON c.id=a.property_id AND a.src='condo' WHERE (${seg.where}) AND c.price>0
+ UNION ALL SELECT s.price FROM assumable_estimate a JOIN sfr s ON s.id=a.property_id AND a.src='sfr' WHERE (${seg.where}) AND s.price>0)`;
+ if (priceFrom) {
+ const pr = (await brokerdb.pool.query(
+ `SELECT ${PCT(0.5)} med, ${PCT(0.1)} lo, ${PCT(0.9)} hi FROM ${priceFrom} pp(price)`)).rows[0];
+ priceMed = pr.med; priceMin = pr.lo; priceMax = pr.hi;
+ }
if (seg.src === 'listing') {
props = (await brokerdb.pool.query(`SELECT count(*)::int n FROM listing WHERE ${seg.where}`)).rows[0].n;
agents = (await brokerdb.pool.query(`SELECT count(DISTINCT bl.broker_id)::int n FROM listing l JOIN broker_listing bl ON bl.listing_id=l.id WHERE ${seg.where}`)).rows[0].n;
@@ -503,6 +529,7 @@ app.get('/api/segments', async (req, res) => {
}
const noAgents = (seg.src === 'sfr' || seg.src === 'assumable') && agents === 0;
out.push({ key, label: seg.label, product: seg.product, hook: seg.hook, props, agents,
+ priceMed, priceMin, priceMax,
note: noAgents ? 'Listings matched, but no public listing agents captured yet (HONEST: agent edge sparse).'
: seg.src === 'assumable' ? 'HEURISTIC estimate — likely-assumable FHA/VA, NOT title-verified. Verify in title records before pitching.' : null });
}
← 80c2838 additive: Assumable FHA/VA call segment + read-only /api/ass
·
back to Commercialrealestate
·
auto-save: 2026-06-28T17:15:46 (1 files) — public/crcp.html 495d8d6 →