[object Object]

← back to Commercialrealestate

feat(index): side-by-side Compare modal for shortlisted listings — 17 aligned metric rows (price, $/unit, $/SF, cap, DSCR, CoC, cash needed/flow, rent, beds/baths/sqft, year, warrantability, scores), sticky label col + header, star-2-first hint. Contrarian-gated, 0 errors.

14963d5539b7dd79c46346c4a31d5e275b425204 · 2026-07-02 11:21:07 -0700 · Steve Abrams

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

Files touched

Diff

commit 14963d5539b7dd79c46346c4a31d5e275b425204
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 2 11:21:07 2026 -0700

    feat(index): side-by-side Compare modal for shortlisted listings — 17 aligned metric rows (price, $/unit, $/SF, cap, DSCR, CoC, cash needed/flow, rent, beds/baths/sqft, year, warrantability, scores), sticky label col + header, star-2-first hint. Contrarian-gated, 0 errors.
    
    Officer: vp-engineering
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 public/index.html | 44 ++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 44 insertions(+)

diff --git a/public/index.html b/public/index.html
index 78b595c..0ad8405 100644
--- a/public/index.html
+++ b/public/index.html
@@ -191,6 +191,17 @@
   .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; }
+  /* compare overlay */
+  #cmpov { display:none; position:fixed; inset:0; background:rgba(0,0,0,.6); z-index:60; align-items:flex-start; justify-content:center; padding:40px 14px; overflow:auto; }
+  #cmpov.on { display:flex; }
+  #cmpmodal { width:min(1100px,97vw); background:var(--card); border:1px solid var(--line); border-radius:15px; padding:20px 22px; position:relative; }
+  .cmpx { position:absolute; top:12px; right:14px; background:none; border:0; color:var(--mut); font-size:22px; cursor:pointer; }
+  .cmptbl { width:100%; border-collapse:collapse; font-size:13px; }
+  .cmptbl th, .cmptbl td { padding:8px 11px; border-top:1px solid var(--line); text-align:right; white-space:nowrap; }
+  .cmptbl thead th { position:sticky; top:0; background:var(--card); text-align:right; vertical-align:bottom; color:var(--ink); }
+  .cmptbl .cmpk, .cmptbl thead th.cmpk { text-align:left; color:var(--mut); position:sticky; left:0; background:var(--card); }
+  .cmptbl thead th .cmpsub { color:var(--mut); font-size:11px; font-weight:400; }
+  .cmphint { color:var(--mut); padding:24px 4px; }
 </style>
 </head>
 <body>
@@ -433,6 +444,7 @@
     </span>
     <div class="right">
       <button id="shortbtn" class="ex" title="Show only your shortlisted (starred) listings">⭐ Shortlist (0)</button>
+      <button id="cmpbtn" class="ex" title="Compare your shortlisted listings side by side">⚖ Compare</button>
       <button id="csvbtn" class="ex" title="Download the current filtered + sorted view (visible columns) as CSV">⬇ CSV</button>
       <button id="linkbtn" class="ex" title="Copy a shareable link to this exact filtered view">🔗 Copy link</button>
       <label>Sort</label>
@@ -457,6 +469,7 @@
   </form>
 </div>
 
+<div id="cmpov"><div id="cmpmodal"><button class="cmpx" aria-label="Close" onclick="document.getElementById('cmpov').classList.remove('on')">✕</button><h3 style="margin:0 0 10px">⚖ Compare shortlist</h3><div id="cmpbody"></div></div></div>
 <script src="finance.js"></script>
 <script src="deal-score.js"></script>
 <script>
@@ -991,6 +1004,35 @@ function exportCSV(){
   const a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([lines.join('\n')],{type:'text/csv'})); a.download='cre-explorer-'+rows.length+'.csv'; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href),1000);
 }
 function copyShareLink(){ const t=$('#linkbtn'); try{ navigator.clipboard.writeText(location.href); if(t){ const o=t.textContent; t.textContent='✓ Copied'; setTimeout(()=>t.textContent=o,1400); } }catch(e){ if(t) t.textContent='⌘C to copy'; } }
+// ---- side-by-side compare of shortlisted listings ----
+const CMP=[
+  {l:'Type', f:p=>esc(p.type||'—')},
+  {l:'Price', f:p=>fmt(p.price)},
+  {l:'$/unit', f:p=>{const v=ppu(p);return v?'$'+v.toLocaleString():'—';}},
+  {l:'$/SF', f:p=>{const v=ppsf(p);return v?'$'+v.toLocaleString():'—';}},
+  {l:'Units', f:p=>p.units??'—'},
+  {l:'Cap rate', f:p=>p.cap_rate!=null?p.cap_rate+'%':'—'},
+  {l:'DSCR', f:p=>p.finance&&p.finance.dscr!=null?p.finance.dscr:'—'},
+  {l:'Cash-on-cash', f:p=>p.finance&&p.finance.coc!=null?p.finance.coc+'%':'—'},
+  {l:'Cash needed', f:p=>p.finance?fmt(p.finance.cashNeeded):'—'},
+  {l:'Cash flow/yr', f:p=>p.finance?fmt(p.finance.cashFlow):'—'},
+  {l:'Est. rent', f:p=>{const e=rentEstimate(p);return e?'$'+e.std.toLocaleString()+'/mo':'—';}},
+  {l:'Beds / Baths', f:p=>(p.beds!=null||p.baths!=null)?((p.beds??'—')+' / '+(p.baths??'—')):'—'},
+  {l:'SqFt', f:p=>p.sqft?(+p.sqft).toLocaleString():'—'},
+  {l:'Year built', f:p=>p.year_built||'—'},
+  {l:'Warrantability', f:p=>esc(p.warrantable_status||'—')},
+  {l:'Deal score', f:p=>p.deal&&p.deal.score!=null?p.deal.score:'—'},
+  {l:'Invest score', f:p=>p.composite??'—'},
+];
+function openCompare(){
+  const ov=$('#cmpov'), body=$('#cmpbody');
+  const items=DATA.ranked.filter(p=>SHORT.has(p.id));
+  if(items.length<2){ body.innerHTML='<div class="cmphint">Star at least 2 listings (the ★ on any card) to compare them side by side.</div>'; ov.classList.add('on'); return; }
+  const head='<tr><th class="cmpk"></th>'+items.map(p=>`<th>${esc(p.address)}<div class="cmpsub">${esc(p.city)}</div></th>`).join('')+'</tr>';
+  const rows=CMP.map(m=>`<tr><td class="cmpk">${m.l}</td>`+items.map(p=>`<td>${m.f(p)}</td>`).join('')+'</tr>').join('');
+  body.innerHTML=`<table class="cmptbl"><thead>${head}</thead><tbody>${rows}</tbody></table>`;
+  ov.classList.add('on');
+}
 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(r){
   const sb=$('#statsbar'); if(!sb) return;
@@ -1276,6 +1318,8 @@ fetch('data/ranked.json').then(r=>r.json()).then(async d=>{
   $('#chatform').onsubmit=e=>{ e.preventDefault(); const t=$('#q').value.trim(); if(!t) return; $('#q').value=''; ask(t); };
   $('#grid').onclick=e=>{ const st=e.target.closest('[data-star]'); if(st){ toggleStar(st.dataset.star); st.classList.toggle('on',SHORT.has(st.dataset.star)); const sb=$('#shortbtn'); if(sb) sb.textContent='⭐ Shortlist ('+SHORT.size+')'; if(F.shortlist) render(); return; } const th=e.target.closest('.listtbl th[data-sortk]'); if(th){ const k=th.dataset.sortk; if(colSort&&colSort.key===k) colSort.dir*=-1; else colSort={key:k,dir:['type','firm','warr','broker','status','zip','addr','rec'].includes(k)?1:-1}; render(); return; } const b=e.target.closest('.lcbtn'); if(b){ fetchLiveComps(b.dataset.id); return; } const h=e.target.closest('.histbtn'); if(h) fetchHistory(h); };
   $('#shortbtn').onclick=()=>{ F.shortlist=!F.shortlist; render(); };
+  $('#cmpbtn').onclick=openCompare;
+  $('#cmpov').addEventListener('click',e=>{ if(e.target.id==='cmpov') e.currentTarget.classList.remove('on'); });
 
   window.DATA_READY = true;
   recompute(); syncChips(); render();

← d9c099f feat(index): aggregate stats strip — median price / $-per-un  ·  back to Commercialrealestate  ·  feat(index): live facet counts (DTD verdict A, 2/3 Codex+Cla b5a7c9f →