[object Object]

← back to Commercialrealestate

CRCP modals: graceful no-coverage state + modal-open telemetry (DTD-C rider)

ee6f9c41c5aeeac870e32b5a5445647fd09fd70a · 2026-07-31 10:32:21 -0700 · Steve Abrams

- propBuckets + broker-grid modal now render Closed/Withdrawn/area-sold ONLY when
  populated; collapse the gaps into one honest 'Coverage: active listings only —
  <gaps> need a paid records source' line (no hollow 'Closed (0)' note-sections)
- POST /api/track appends broker/firm modal-opens to data/modal-opens.jsonl
  (best-effort, never throws) — demand signal for the parked paid-data track
- exclude data/modal-opens.jsonl from rsync (.deploy.conf) + git so deploys don't
  clobber prod's accumulating telemetry
Verified: parse-clean, track appends, empty-bucket broker shows coverage line not
hollow sections, modal opens logged, zero JS errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files touched

Diff

commit ee6f9c41c5aeeac870e32b5a5445647fd09fd70a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jul 31 10:32:21 2026 -0700

    CRCP modals: graceful no-coverage state + modal-open telemetry (DTD-C rider)
    
    - propBuckets + broker-grid modal now render Closed/Withdrawn/area-sold ONLY when
      populated; collapse the gaps into one honest 'Coverage: active listings only —
      <gaps> need a paid records source' line (no hollow 'Closed (0)' note-sections)
    - POST /api/track appends broker/firm modal-opens to data/modal-opens.jsonl
      (best-effort, never throws) — demand signal for the parked paid-data track
    - exclude data/modal-opens.jsonl from rsync (.deploy.conf) + git so deploys don't
      clobber prod's accumulating telemetry
    Verified: parse-clean, track appends, empty-bucket broker shows coverage line not
    hollow sections, modal opens logged, zero JS errors.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---
 .deploy.conf            |  2 +-
 .gitignore              |  1 +
 public/broker-grid.html | 11 ++++++++---
 public/crcp.html        | 19 +++++++++++++++----
 scripts/serve.js        | 16 ++++++++++++++++
 5 files changed, 41 insertions(+), 8 deletions(-)

diff --git a/.deploy.conf b/.deploy.conf
index b5597e2..bf4cf4d 100644
--- a/.deploy.conf
+++ b/.deploy.conf
@@ -4,4 +4,4 @@ HEALTH_URL=http://localhost:9911/healthz
 INSTALL_CMD="npm ci --omit=dev"
 BUILD_CMD=""
 REQUIRED_ENVS=""
-RSYNC_EXTRA_EXCLUDES="tmp 5x DW-Wins-Dashboard.png data/afternoon-report.html data/.pre-afternoon-ids.json data/agent-contacts.json assessor.sqlite *.bak data/morning-report.html data/condos-report.html"
+RSYNC_EXTRA_EXCLUDES="tmp 5x DW-Wins-Dashboard.png data/afternoon-report.html data/.pre-afternoon-ids.json data/agent-contacts.json assessor.sqlite *.bak data/morning-report.html data/condos-report.html data/modal-opens.jsonl"
diff --git a/.gitignore b/.gitignore
index a137049..f9e7be8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,3 +28,4 @@ data/raw/*.pre-*.bak
 
 data/edgar-reit-deals.json
 data/deals-flow.json
+data/modal-opens.jsonl
diff --git a/public/broker-grid.html b/public/broker-grid.html
index dfde2ed..322c424 100644
--- a/public/broker-grid.html
+++ b/public/broker-grid.html
@@ -313,11 +313,14 @@ const fdate=s=>{ if(!s) return ''; try{ return new Date(s).toLocaleDateString(un
 // sale/deed dates are date-only (no time-of-day exists in the source), so those keep fdate.
 const fdt=s=>{ if(!s) return ''; try{ const d=new Date(s); const hasTime=/T\d\d:\d\d/.test(String(s))&&!/T0[0-7]:00:00/.test(String(s)); return d.toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'})+(hasTime?', '+d.toLocaleTimeString(undefined,{hour:'numeric',minute:'2-digit'}):''); }catch(e){ return ''; } };
 const atb=t=>t==='residential'?'<span class="atype res">Residential</span>':'<span class="atype com">Commercial</span>';
+// Fire-and-forget modal-open telemetry (DTD-C rider — demand signal for the parked paid-data track).
+function track(ev,o){ try{ fetch('/api/track',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(Object.assign({ev},o)),keepalive:true}).catch(()=>{}); }catch(e){} }
 async function openContact(name,id){
   const ov=$('#ov'), mb=$('#mbody'); ov.classList.add('on'); mb.innerHTML='<div class="mut">loading '+esc(name)+'…</div>';
   const qs=id?'id='+encodeURIComponent(id):'name='+encodeURIComponent(name);
   let d; try{ d=await (await fetch('/api/broker?'+qs)).json(); }catch(e){ mb.innerHTML='<div class="mut">error</div>'; return; }
   if(d.error){ mb.innerHTML='<div class="mut">'+esc(d.error)+'</div>'; return; }
+  track('broker_modal',{kind:'broker',id:d.broker&&d.broker.id,name:d.broker&&d.broker.name,active:(d.current||[]).length,closed:(d.closed||[]).length,withdrawn:(d.expired||[]).length});
   const b=d.broker, w=b.website?(/^https?:/.test(b.website)?b.website:'https://'+b.website):null;
   const li=b.linkedin?(/^https?:/.test(b.linkedin)?b.linkedin:'https://'+b.linkedin):null;
   mb.innerHTML='<h3>'+esc(b.name)+atb(b.agent_type)+'</h3><div class="mfirm">'+esc(b.firm||'—')+(b.title?' · '+esc(b.title):'')+(b.license?' · DRE/MLS '+esc(b.license):'')+'</div>'
@@ -327,9 +330,11 @@ async function openContact(name,id){
     +(w?'<a href="'+esc(w)+'" target="_blank" rel="noopener noreferrer">🌐 '+esc(w.replace(/^https?:\/\/(www\.)?/,'').replace(/\/$/,''))+'</a>':'<span class="mut">🌐 —</span>')
     +(li?'<a href="'+esc(li)+'" target="_blank" rel="noopener noreferrer">in↗</a>':'')+'</div>'
     +'<div class="sec"><h4>Active / listed ('+d.current.length+')</h4>'+(d.current.length?d.current.map(l=>'<div class="litem"><span>'+esc(l.address)+', '+esc(l.city)+(l.zip?' '+esc(l.zip):'')+'</span><span class="m">'+esc(l.type)+' · '+money(l.price)+(l.units?' · '+l.units+'u':'')+(l.cap_rate?' · '+l.cap_rate+'% cap':'')+(l.listed_at?' · listed '+fdt(l.listed_at):'')+'</span></div>').join(''):'<div class="mut">none in our set</div>')+'</div>'
-    +'<div class="sec"><h4>Closed ('+((d.closed&&d.closed.length)||0)+')</h4>'+((d.closed&&d.closed.length)?d.closed.map(l=>'<div class="litem"><span>'+esc(l.address)+', '+esc(l.city)+'</span><span class="m">'+money(l.sold_price)+(l.sold_date?' · '+fdate(l.sold_date):'')+(l.type?' · '+esc(l.type):'')+'</span></div>').join(''):'<div class="mut">'+esc(d.closedNote||'none')+'</div>')+'</div>'
-    +'<div class="sec"><h4>Withdrawn / expired / off-market ('+((d.expired&&d.expired.length)||0)+')</h4>'+((d.expired&&d.expired.length)?d.expired.map(x=>'<div class="litem"><span>'+esc(x.address)+', '+esc(x.city)+'</span><span class="m">'+money(x.list_price)+' → sold '+money(x.sold_price)+' · '+fdate(x.sold_date)+(x.mls?' · MLS '+esc(x.mls):'')+(x.url?' · <a href="'+esc(x.url)+'" target="_blank" rel="noopener noreferrer">↗</a>':'')+'</span></div>').join(''):'<div class="mut">'+esc(d.expiredNote||'none')+'</div>')+'</div>'
-    +'<div class="sec"><h4>Sales — area sold ('+((d.comps&&d.comps.length)||0)+')</h4>'+((d.comps&&d.comps.length)?d.comps.map(c=>'<div class="litem"><span>'+esc(c.address)+', '+esc(c.city)+(c.beds?' · '+c.beds+'bd':'')+(c.baths?'/'+c.baths+'ba':'')+(c.sqft?' · '+Number(c.sqft).toLocaleString()+'sf':'')+'</span><span class="m">'+money(c.sold_price)+' · '+fdate(c.sold_date)+(c.mls?' · MLS '+esc(c.mls):'')+(c.url?' · <a href="'+esc(c.url)+'" target="_blank" rel="noopener noreferrer">↗</a>':'')+'</span></div>').join(''):'<div class="mut">no area sales</div>')+'</div>';
+    // DTD-C rider: render Closed/Withdrawn/area-sold ONLY when populated; collapse the gaps into one coverage line.
+    +((d.closed&&d.closed.length)?'<div class="sec"><h4>Closed ('+d.closed.length+')</h4>'+d.closed.map(l=>'<div class="litem"><span>'+esc(l.address)+', '+esc(l.city)+'</span><span class="m">'+money(l.sold_price)+(l.sold_date?' · '+fdate(l.sold_date):'')+(l.type?' · '+esc(l.type):'')+'</span></div>').join('')+'</div>':'')
+    +((d.expired&&d.expired.length)?'<div class="sec"><h4>Withdrawn / expired / off-market ('+d.expired.length+')</h4>'+d.expired.map(x=>'<div class="litem"><span>'+esc(x.address)+', '+esc(x.city)+'</span><span class="m">'+money(x.list_price)+' → sold '+money(x.sold_price)+' · '+fdate(x.sold_date)+(x.mls?' · MLS '+esc(x.mls):'')+(x.url?' · <a href="'+esc(x.url)+'" target="_blank" rel="noopener noreferrer">↗</a>':'')+'</span></div>').join('')+'</div>':'')
+    +((d.comps&&d.comps.length)?'<div class="sec"><h4>Sales — area sold ('+d.comps.length+')</h4>'+d.comps.map(c=>'<div class="litem"><span>'+esc(c.address)+', '+esc(c.city)+(c.beds?' · '+c.beds+'bd':'')+(c.baths?'/'+c.baths+'ba':'')+(c.sqft?' · '+Number(c.sqft).toLocaleString()+'sf':'')+'</span><span class="m">'+money(c.sold_price)+' · '+fdate(c.sold_date)+(c.mls?' · MLS '+esc(c.mls):'')+(c.url?' · <a href="'+esc(c.url)+'" target="_blank" rel="noopener noreferrer">↗</a>':'')+'</span></div>').join('')+'</div>':'')
+    +((!(d.closed&&d.closed.length)||!(d.expired&&d.expired.length))?'<div class="mut" style="margin-top:6px;opacity:.85">📊 Coverage: active listings only'+(d.current.length?'':' — none attributed here yet')+'. '+[!(d.closed&&d.closed.length)?'closed':'',!(d.expired&&d.expired.length)?'withdrawn/expired':''].filter(Boolean).join(' & ')+' history isn’t in free public records (needs a paid records source).</div>':'');
 }
 document.addEventListener('click',e=>{ const fm=e.target.closest('.findmail'); if(fm){ findEmail(fm.dataset.id); return; } const row=e.target.closest('.brow'); if(row && e.target.tagName!=='A' && e.target.tagName!=='BUTTON'){ openContact(row.dataset.name,row.dataset.id); } if(e.target.id==='ov') $('#ov').classList.remove('on'); });
 $('#thead').addEventListener('click',e=>{const th=e.target.closest('th');if(!th)return;const k=th.dataset.k;
diff --git a/public/crcp.html b/public/crcp.html
index e684e41..780d0d0 100644
--- a/public/crcp.html
+++ b/public/crcp.html
@@ -125,12 +125,21 @@ const money=n=>n?'$'+Number(n).toLocaleString():'—';
 const fd10=s=>(s||'').toString().slice(0,10);   // public-record dates are date-only
 // date+TIME — only for genuine capture timestamps (listed_at); public sale/deed dates stay date-only
 const fdt=s=>{ if(!s)return''; try{ const d=new Date(s),S=String(s); const t=/T\d\d:\d\d/.test(S)&&!/T0[0-7]:00:00/.test(S); return d.toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'})+(t?', '+d.toLocaleTimeString(undefined,{hour:'numeric',minute:'2-digit'}):''); }catch(e){ return fd10(s); } };
-// Shared property-bucket renderer for broker + firm modals (Active/Closed/Withdrawn).
+// Shared property-bucket renderer for broker + firm modals (DTD-C rider 2026-07-31): show ONLY
+// the buckets that have real data — never a hollow "Closed (0)" note-section — and collapse the
+// gaps into ONE honest coverage line, so the modal reads "active listings only" cleanly instead
+// of looking broken. Active is always shown (it's the covered dimension).
 function propBuckets(d){
-  return `<div class="sec"><h4>Active / listed (${(d.current||[]).length})</h4>${(d.current||[]).length?d.current.map(l=>`<div class="litem"><span class="a">${l.address}, ${l.city}${l.zip?' '+l.zip:''}</span><span class="m">${l.type} · ${money(l.price)}${l.units?' · '+l.units+'u':''}${l.cap_rate?' · '+l.cap_rate+'% cap':''}${l.listed_at?' · listed '+fdt(l.listed_at):''}</span></div>`).join(''):'<div class="meta">none in our set</div>'}</div>`
-   + `<div class="sec"><h4>Closed (${(d.closed||[]).length})</h4>${(d.closed||[]).length?d.closed.map(l=>`<div class="litem"><span class="a">${l.address}, ${l.city}</span><span class="m">${money(l.sold_price)}${l.sold_date?' · '+fd10(l.sold_date):''}${l.type?' · '+l.type:''}</span></div>`).join(''):'<div class="meta">'+(d.closedNote||'none')+'</div>'}</div>`
-   + `<div class="sec"><h4>Withdrawn / expired / off-market (${(d.expired||[]).length})</h4>${(d.expired||[]).length?d.expired.map(x=>`<div class="litem"><span class="a">${x.address}, ${x.city}</span><span class="m">${money(x.list_price)} → sold ${money(x.sold_price)} · ${fd10(x.sold_date)}${x.mls?' · MLS '+x.mls:''}${x.url?` · <a href="${x.url}" target="_blank" rel="noopener noreferrer">↗</a>`:''}</span></div>`).join(''):'<div class="meta">'+(d.expiredNote||'none')+'</div>'}</div>`;
+  const active=d.current||[], closed=d.closed||[], expired=d.expired||[];
+  let html=`<div class="sec"><h4>Active / listed (${active.length})</h4>${active.length?active.map(l=>`<div class="litem"><span class="a">${l.address}, ${l.city}${l.zip?' '+l.zip:''}</span><span class="m">${l.type} · ${money(l.price)}${l.units?' · '+l.units+'u':''}${l.cap_rate?' · '+l.cap_rate+'% cap':''}${l.listed_at?' · listed '+fdt(l.listed_at):''}</span></div>`).join(''):'<div class="meta">none in our set</div>'}</div>`;
+  if(closed.length) html+=`<div class="sec"><h4>Closed (${closed.length})</h4>${closed.map(l=>`<div class="litem"><span class="a">${l.address}, ${l.city}</span><span class="m">${money(l.sold_price)}${l.sold_date?' · '+fd10(l.sold_date):''}${l.type?' · '+l.type:''}</span></div>`).join('')}</div>`;
+  if(expired.length) html+=`<div class="sec"><h4>Withdrawn / expired / off-market (${expired.length})</h4>${expired.map(x=>`<div class="litem"><span class="a">${x.address}, ${x.city}</span><span class="m">${money(x.list_price)} → sold ${money(x.sold_price)} · ${fd10(x.sold_date)}${x.mls?' · MLS '+x.mls:''}${x.url?` · <a href="${x.url}" target="_blank" rel="noopener noreferrer">↗</a>`:''}</span></div>`).join('')}</div>`;
+  const gaps=[]; if(!closed.length) gaps.push('closed'); if(!expired.length) gaps.push('withdrawn/expired');
+  if(gaps.length) html+=`<div class="meta" style="margin-top:6px;opacity:.85">📊 Coverage: active listings only${active.length?'':' — none attributed here yet'}. ${gaps.join(' & ')} history isn’t in free public records (needs a paid records source).</div>`;
+  return html;
 }
+// Fire-and-forget modal-open telemetry (demand signal for the parked paid-data track).
+function track(ev,o){ try{ fetch('/api/track',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(Object.assign({ev},o)),keepalive:true}).catch(()=>{}); }catch(e){} }
 const atype=t=>t==='residential'?'<span class="atype res" title="Residential listing agent (Redfin)">Residential</span>':'<span class="atype com" title="Commercial broker (Crexi)">Commercial</span>';
 async function openContact(name, id, seg){
   window.__seg = seg || null;
@@ -139,6 +148,7 @@ async function openContact(name, id, seg){
   let d; try{ d=await (await fetch('/api/broker?'+qs)).json(); }catch(e){ mb.innerHTML='error'; return; }
   if(d.error){ mb.innerHTML='<div class="meta">'+d.error+'</div>'; return; }
   const b=d.broker, w=b.website?(/^https?:/.test(b.website)?b.website:'https://'+b.website):null;
+  track('broker_modal',{kind:'broker',id:b&&b.id,name:b&&b.name,active:(d.current||[]).length,closed:(d.closed||[]).length,withdrawn:(d.expired||[]).length});
   mb.innerHTML=`<h2>${b.name}${atype(b.agent_type)}</h2><div class="mfirm">${b.firm||'—'}${b.title?' · '+b.title:''}${b.license?' · DRE/MLS '+b.license:''}</div>
     <div class="cinfo">
       ${b.phone?`<a href="tel:${b.phone}">☎ ${b.phone}</a>`:'<span class="m">☎ —</span>'}
@@ -164,6 +174,7 @@ async function openFirm(name){
   let d; try{ d=await (await fetch('/api/firm?name='+encodeURIComponent(name))).json(); }catch(e){ mb.innerHTML='error'; return; }
   if(d.error){ mb.innerHTML='<div class="meta">'+d.error+'</div>'; return; }
   const a=d.agg||{};
+  track('firm_modal',{kind:'firm',name:d.firm&&d.firm.name,active:(d.current||[]).length,closed:(d.closed||[]).length,withdrawn:(d.expired||[]).length});
   mb.innerHTML=`<h2>${d.firm.name}</h2><div class="mfirm">brokerage firm</div>
     <div class="cinfo"><span>👥 ${a.brokers||0} brokers/agents</span><span>🏠 ${a.residential||0} residential</span><span>☎ ${a.phone||0}</span><span>✉ ${a.email||0}</span></div>
     ${propBuckets(d)}
diff --git a/scripts/serve.js b/scripts/serve.js
index 682088b..89cdbc7 100644
--- a/scripts/serve.js
+++ b/scripts/serve.js
@@ -911,6 +911,22 @@ app.get('/api/firm', async (req, res) => {
 // wins; refuses to guess on shared corporate pages — a stranger's email is worse than none.
 // $0 (plain local fetch, no paid API). Found email persists: broker.email locally (Postgres),
 // or written back into data/brokers-snapshot.json on DB-less prod (sticks until next refresh).
+// Modal-open instrumentation (DTD-C rider, 2026-07-31): capture the demand signal for broker/firm
+// detail views so a real driver for paid closed/withdrawn data reveals itself (do users open modals,
+// and do they open ones that lack closed history?). Best-effort JSONL append; never throws; no PII
+// beyond the entity already displayed on the page.
+app.post('/api/track', (req, res) => {
+  try {
+    const b = req.body || {};
+    const rec = { t: new Date().toISOString(), ev: String(b.ev || '').slice(0, 40),
+      kind: String(b.kind || '').slice(0, 12), id: b.id != null ? String(b.id).slice(0, 24) : null,
+      name: String(b.name || '').slice(0, 80),
+      active: +b.active || 0, closed: +b.closed || 0, withdrawn: +b.withdrawn || 0 };
+    fs.appendFile(path.join(ROOT, 'data', 'modal-opens.jsonl'), JSON.stringify(rec) + '\n', () => {});
+  } catch (_) { /* never break a page over telemetry */ }
+  res.json({ ok: true });
+});
+
 const emailHunt = require('./lib/email-hunt');
 // Browser escalation (headless Chrome) when Playwright is available (the Mac); Kamatera prod has
 // no Playwright → loadable() false → the button quietly runs fetch-only. Browser is closed after

← ddf2b47 spec: tool/subscription layer (DTD Option C) — sell the tool  ·  back to Commercialrealestate  ·  crcp /api/firm: dedupe current/closed/expired by address+cit 76508c9 →