← 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 →