[object Object]

← back to Commercialrealestate

feat(mls): feature parity (DTD verdict B, 2/3 Codex+Claude; Qwen dissent D=export-compare) — port ⭐shortlist + aggregate stats strip to the MLS table; shortlist shares the SAME cre_shortlist localStorage key + listing ids as the Explorer, so starring is UNIFIED across index<->mls. Stats: count/median price/$-unit/$-SF/avg cap/median year over the filtered set. Star column + grid-card stars, shortlist toggle filters. Contrarian-gated, 0 errors.

7e7b8f4c7c5f026d6138121976ab5053fedcbdc4 · 2026-07-02 12:52:23 -0700 · Steve Abrams

Officer: vp-engineering
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit 7e7b8f4c7c5f026d6138121976ab5053fedcbdc4
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 2 12:52:23 2026 -0700

    feat(mls): feature parity (DTD verdict B, 2/3 Codex+Claude; Qwen dissent D=export-compare) — port ⭐shortlist + aggregate stats strip to the MLS table; shortlist shares the SAME cre_shortlist localStorage key + listing ids as the Explorer, so starring is UNIFIED across index<->mls. Stats: count/median price/$-unit/$-SF/avg cap/median year over the filtered set. Star column + grid-card stars, shortlist toggle filters. Contrarian-gated, 0 errors.
    
    Officer: vp-engineering
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 public/mls.html | 53 +++++++++++++++++++++++++++++++++++++++++++++--------
 1 file changed, 45 insertions(+), 8 deletions(-)

diff --git a/public/mls.html b/public/mls.html
index e6b8060..f356dc3 100644
--- a/public/mls.html
+++ b/public/mls.html
@@ -69,6 +69,14 @@
   .rail .rsec.collapsed > h4::after { transform:rotate(-90deg); }
   .rail .rsec.collapsed > *:not(h4) { display:none !important; }
   .mlsempty { padding:60px 20px; text-align:center; color:var(--mut); }
+  .starbtn { background:none; border:0; color:var(--mut); font-size:16px; line-height:1; cursor:pointer; padding:0; }
+  .starbtn:hover, .starbtn.on { color:var(--warn); }
+  #shortbtn.shorton { border-color:var(--warn); color:var(--warn); }
+  .statsbar { display:flex; flex-wrap:wrap; gap:8px; align-items:center; padding:9px 22px; border-bottom:1px solid var(--line); }
+  .statsbar:empty { display:none; }
+  .statpill { font-size:12px; color:var(--ink); background:var(--card); border:1px solid var(--line); border-radius:8px; padding:4px 10px; }
+  .statpill .sl { color:var(--mut); } .statpill b { font-variant-numeric:tabular-nums; }
+  table.mls td.startd, table.mls th.starth { width:26px; text-align:center; padding:5px 4px; }
 </style>
 </head>
 <body>
@@ -78,6 +86,7 @@
   <span class="spacer"></span>
   <input class="search" id="q" type="text" placeholder="search anything — address, city, firm, type, year…">
   <div class="toggle" id="view"><button data-v="table" class="on">▦ Table</button><button data-v="grid">▤ Grid</button></div>
+  <button class="csvbtn" id="shortbtn" title="Show only your shortlisted (starred) listings — shared with the Explorer">⭐ Shortlist (0)</button>
   <button class="csvbtn" id="csv" title="Export current rows to CSV">⬇ CSV</button>
   <span class="count" id="count">…</span>
 </header>
@@ -94,6 +103,7 @@
   </div>
 </aside>
 <div class="wrap">
+  <div class="statsbar" id="statsbar"></div>
   <div id="tableView" class="tblwrap"><table class="mls"><thead id="thead"></thead><tbody id="tbody"></tbody></table></div>
   <div id="gridView" class="grid hide"></div>
 </div>
@@ -126,7 +136,27 @@ const COLS=[
   {k:'source',l:'Source',t:'link'},
 ];
 let DATA=[], view='table', sortKey='rank', sortDir=1, q='';
-const F={types:new Set(),cities:new Set(),status:new Set(),pmin:null,pmax:null,unwarrantable:false,nonqm:false};
+const F={types:new Set(),cities:new Set(),status:new Set(),pmin:null,pmax:null,unwarrantable:false,nonqm:false,shortlist:false};
+// shared shortlist with the Explorer (same localStorage key + same listing ids)
+const SHORT=new Set((function(){ try{ return JSON.parse(localStorage.getItem('cre_shortlist')||'[]'); }catch(e){ return []; } })());
+function saveShort(){ try{ localStorage.setItem('cre_shortlist', JSON.stringify([...SHORT])); }catch(e){} }
+function toggleStar(id){ SHORT.has(id)?SHORT.delete(id):SHORT.add(id); saveShort(); }
+function updateShortBtn(){ const sb=$('#shortbtn'); if(sb){ sb.textContent='⭐ Shortlist ('+SHORT.size+')'; sb.classList.toggle('shorton',F.shortlist); } }
+function median(a){ if(!a.length) return null; const s=a.slice().sort((x,y)=>x-y); const m=Math.floor(s.length/2); return s.length%2?s[m]:(s[m-1]+s[m])/2; }
+function renderStats(rows){ const sb=$('#statsbar'); if(!sb) return; if(!rows.length){ sb.innerHTML=''; return; }
+  const prices=rows.map(r=>+r.price).filter(v=>v>0);
+  const ppu=rows.map(r=>r.units?+r.price/r.units:null).filter(v=>v>0);
+  const pps=rows.map(r=>r.sqft?+r.price/r.sqft:null).filter(v=>v>0);
+  const caps=rows.map(r=>r.cap_rate).filter(v=>v!=null&&v!=='').map(Number).filter(v=>!isNaN(v));
+  const yrs=rows.map(r=>+r.year_built).filter(v=>v>1800);
+  const pill=(l,v)=>v==null?'':`<span class="statpill"><span class="sl">${l}</span> <b>${v}</b></span>`;
+  sb.innerHTML=pill('Count',rows.length)
+    +pill('Median price',prices.length?'$'+Math.round(median(prices)).toLocaleString():null)
+    +pill('Median $/unit',ppu.length?'$'+Math.round(median(ppu)).toLocaleString():null)
+    +pill('Median $/SF',pps.length?'$'+Math.round(median(pps)).toLocaleString():null)
+    +pill('Avg cap',caps.length?(caps.reduce((a,b)=>a+b,0)/caps.length).toFixed(2)+'% ('+caps.length+')':null)
+    +pill('Median year',yrs.length?Math.round(median(yrs)):null);
+}
 function mapCondo(c){ return { id:c.id, address:c.address, city:c.city, zip:c.zip, type:'Condo', price:+c.price||0, units:1, cap_rate:null, year_built:c.year_built||null, status:'Active', source:c.source, firm:c.firm_name||null, beds:c.beds, baths:c.baths, sqft:c.sqft, hoa:c.hoa, warrantable_status:c.warrantable_status, broker_name:c.broker_name, project_name:c.project_name }; }
 let VISCOL=(function(){let s={};try{s=JSON.parse(localStorage.getItem('mlsCols')||'{}');}catch(e){}return s;})();
 function colVis(k){ if(k in VISCOL) return !!VISCOL[k]; const c=COLS.find(x=>x.k===k); return c?c.def!==0:true; }
@@ -157,7 +187,7 @@ function isNonQM(r){ const t=(r.type||'').toLowerCase();
   if(r.type==='Condo' && r.warrantable_status && r.warrantable_status!=='fha_approved') return true;
   if(/multi/.test(t) && r.units>=1 && r.units<=9) return true;
   if(/mixed/.test(t)) return true; return false; }
-function anySelection(){ return F.types.size||F.cities.size||F.status.size||F.pmin!=null||F.pmax!=null||F.unwarrantable||F.nonqm||(q&&q.length); }
+function anySelection(){ return F.types.size||F.cities.size||F.status.size||F.pmin!=null||F.pmax!=null||F.unwarrantable||F.nonqm||F.shortlist||(q&&q.length); }
 function passFacets(r){
   if(F.types.size && !F.types.has(r.type)) return false;
   if(F.cities.size && !F.cities.has(r.city)) return false;
@@ -166,6 +196,7 @@ function passFacets(r){
   if(F.pmax!=null && !(+r.price<=F.pmax)) return false;
   if(F.unwarrantable && !(r.type==='Condo' && r.warrantable_status && r.warrantable_status!=='fha_approved')) return false;
   if(F.nonqm && !isNonQM(r)) return false;
+  if(F.shortlist && !SHORT.has(r.id)) return false;
   return true;
 }
 const cval=(r,c)=>c.calc?c.calc(r):r[c.k];
@@ -191,19 +222,23 @@ function filtered(){
   return rows;
 }
 function render(){
+  updateShortBtn();
   if(!anySelection()){
     $('#count').textContent=`${DATA.length} loaded · pick a filter or search to view`;
+    $('#statsbar').innerHTML='';
     $('#tableView').classList.add('hide'); $('#gridView').classList.remove('hide');
-    $('#gridView').className='grid'; $('#gridView').innerHTML=`<div class="mlsempty">👈 Expand a filter tab or search to begin.<br><span style="font-size:12px">${DATA.length} listings + condos loaded. Pick <b>Asset class</b> (◆ Non-QM · ⚑ Unwarrantable · Condo), a city, status, or a price range.</span></div>`;
+    $('#gridView').className='grid'; $('#gridView').innerHTML=`<div class="mlsempty">👈 Expand a filter tab or search to begin.<br><span style="font-size:12px">${DATA.length} listings + condos loaded. Pick <b>Asset class</b> (◆ Non-QM · ⚑ Unwarrantable · Condo), a city, status, a price range, or ⭐ Shortlist.</span></div>`;
     return;
   }
   const rows=filtered(); const cols=visCols();
+  renderStats(rows);
   $('#count').textContent=`${rows.length} of ${DATA.length} properties`;
+  const star=r=>`<button class="starbtn ${SHORT.has(r.id)?'on':''}" data-star="${r.id}" title="Shortlist">★</button>`;
   // table head
-  $('#thead').innerHTML='<tr>'+cols.map(c=>`<th data-k="${c.k}" class="${sortKey===c.k?(sortDir>0?'asc':'desc'):''}">${esc(c.l)}</th>`).join('')+'</tr>';
+  $('#thead').innerHTML='<tr><th class="starth">⭐</th>'+cols.map(c=>`<th data-k="${c.k}" class="${sortKey===c.k?(sortDir>0?'asc':'desc'):''}">${esc(c.l)}</th>`).join('')+'</tr>';
   if(view==='table'){
     $('#tableView').classList.remove('hide'); $('#gridView').classList.add('hide');
-    $('#tbody').innerHTML=rows.map(r=>`<tr class="prow" data-addr="${esc(r.address||'')}" data-city="${esc(r.city||'')}" style="cursor:pointer">`+cols.map(c=>{
+    $('#tbody').innerHTML=rows.map(r=>`<tr class="prow" data-addr="${esc(r.address||'')}" data-city="${esc(r.city||'')}" style="cursor:pointer"><td class="startd">${star(r)}</td>`+cols.map(c=>{
       const cls=(c.t==='money'||c.t==='n'||c.t==='pct')?' class="n"':'';
       return `<td${cls}>${cellHTML(r,c)}</td>`;
     }).join('')+'</tr>').join('');
@@ -211,12 +246,12 @@ function render(){
     $('#tableView').classList.add('hide'); $('#gridView').classList.remove('hide');
     const bodyCols=cols.filter(c=>!['address','city','zip','type'].includes(c.k));
     $('#gridView').innerHTML=rows.map(r=>`<div class="gc prow" data-addr="${esc(r.address||'')}" data-city="${esc(r.city||'')}" style="cursor:pointer">
-      <div class="addr">${esc(r.address||'—')}</div><div class="sub">${esc(r.city||'')} ${esc(r.zip||'')} · ${esc(r.type||'')}</div>`
+      <div style="display:flex;justify-content:space-between;gap:8px"><div class="addr">${esc(r.address||'—')}</div>${star(r)}</div><div class="sub">${esc(r.city||'')} ${esc(r.zip||'')} · ${esc(r.type||'')}</div>`
       +bodyCols.map(c=>`<div class="row"><span class="k">${esc(c.l)}</span><span class="v">${cellHTML(r,c)}</span></div>`).join('')
       +`</div>`).join('');
   }
 }
-$('#thead').addEventListener('click',e=>{ const th=e.target.closest('th'); if(!th) return; const k=th.dataset.k;
+$('#thead').addEventListener('click',e=>{ const th=e.target.closest('th'); if(!th) return; const k=th.dataset.k; if(!k) return;
   if(sortKey===k) sortDir*=-1; else { sortKey=k; sortDir=(k==='address'||k==='city'||k==='firm'||k==='type')?1:-1; } render(); });
 // click a property → 100-yr history popup (address-only, no names)
 async function propHistory(address,city){
@@ -235,13 +270,15 @@ async function propHistory(address,city){
   if(!a && !d.sales.length && !(d.permits||[]).length) h+='<div class="ph-mut">No history on file yet for this address. The 2.43M-parcel assessor roll is still ingesting — year built, beds/baths, and assessed value will appear here once it lands.</div>';
   mb.innerHTML=h;
 }
-document.addEventListener('click',e=>{ const row=e.target.closest('.prow'); if(row && e.target.tagName!=='A'){ propHistory(row.dataset.addr,row.dataset.city); }
+document.addEventListener('click',e=>{ const st=e.target.closest('[data-star]'); if(st){ toggleStar(st.dataset.star); st.classList.toggle('on',SHORT.has(st.dataset.star)); updateShortBtn(); if(F.shortlist) render(); return; }
+  const row=e.target.closest('.prow'); if(row && e.target.tagName!=='A'){ propHistory(row.dataset.addr,row.dataset.city); }
   if(e.target.id==='ov'||e.target.classList.contains('ph-x')) $('#ov').classList.remove('on'); });
 $('#q').addEventListener('input',()=>{ q=$('#q').value.trim(); render(); });
 function exportCSV(){ const rows=filtered(); const cols=visCols(); const cv=(r,c)=>{let v=cval(r,c); v=(v==null?'':String(v)); return '"'+v.replace(/"/g,'""')+'"';};
   const csv=cols.map(c=>'"'+c.l+'"').join(',')+'\n'+rows.map(r=>cols.map(c=>cv(r,c)).join(',')).join('\n');
   const a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv'})); a.download='cre-mls-'+rows.length+'.csv'; a.click(); URL.revokeObjectURL(a.href); }
 $('#csv').addEventListener('click',exportCSV);
+$('#shortbtn').addEventListener('click',()=>{ F.shortlist=!F.shortlist; render(); });
 $('#view').addEventListener('click',e=>{ const b=e.target.closest('button'); if(!b) return;
   document.querySelectorAll('#view button').forEach(x=>x.classList.toggle('on',x===b)); view=b.dataset.v; render(); });
 // left-rail facets + column toggles

← 681a9b8 feat(index): color-by-metric heat cues (DTD verdict D, 2/3 Q  ·  back to Commercialrealestate  ·  afternoon CRE update 2026-07-02 bf0b600 →