← back to Commercialrealestate
feat(index): shortlist — ⭐ star any listing (grid + list), device-local persisted set, '⭐ Shortlist (N)' toggle filters to starred only, removable active-filter chip. Contrarian-gated, 0 errors.
8b66d89f1e6aede9feb3a23f0127801279cc4753 · 2026-07-02 11:13:10 -0700 · Steve Abrams
Officer: vp-engineering
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
Diff
commit 8b66d89f1e6aede9feb3a23f0127801279cc4753
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jul 2 11:13:10 2026 -0700
feat(index): shortlist — ⭐ star any listing (grid + list), device-local persisted set, '⭐ Shortlist (N)' toggle filters to starred only, removable active-filter chip. Contrarian-gated, 0 errors.
Officer: vp-engineering
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
public/index.html | 27 ++++++++++++++++++++++-----
1 file changed, 22 insertions(+), 5 deletions(-)
diff --git a/public/index.html b/public/index.html
index 49e4427..01e903e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -181,6 +181,11 @@
.afchip:hover { background:rgba(88,166,255,.2); } .afchip .xx { opacity:.65; font-weight:700; }
.afreset { background:transparent; border:1px solid #7d2b28; color:var(--bad); border-radius:14px; padding:4px 11px; font-size:12px; cursor:pointer; margin-left:auto; }
.afreset:hover { background:rgba(248,81,73,.1); }
+ /* shortlist stars */
+ .starbtn { background:none; border:0; color:var(--mut); font-size:18px; line-height:1; cursor:pointer; padding:0; }
+ .starbtn:hover, .starbtn.on { color:var(--warn); }
+ .listtbl .starbtn { font-size:14px; margin-right:5px; vertical-align:middle; }
+ #shortbtn.shorton { background:rgba(210,153,34,.14); border-color:var(--warn); color:var(--warn); }
</style>
</head>
<body>
@@ -422,6 +427,7 @@
<button data-view="list">☰ List</button>
</span>
<div class="right">
+ <button id="shortbtn" class="ex" title="Show only your shortlisted (starred) listings">⭐ Shortlist (0)</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>
@@ -579,6 +585,9 @@ function vis(k){ return !!VIS[k]; }
function saveVis(){ try{ localStorage.setItem('cre_fields', JSON.stringify(VIS)); }catch(e){} }
let view = localStorage.getItem('cre_view') || 'grid';
let colSort = null; // {key, dir} — click-to-sort on a List column; overrides the sort dropdown when set
+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 buildFieldToggles(){
const host=$('#fFields'); if(!host) return;
host.innerHTML = FIELDS.map(f=>`<label class="ftog"><input type="checkbox" data-fk="${f.k}" ${vis(f.k)?'checked':''}><span>${f.label}</span>${f.col?'<span class="colbadge" title="Also shows as a column in List view">▤</span>':''}</label>`).join('');
@@ -632,7 +641,7 @@ function renderList(rows){
const arrow=k=>colSort&&colSort.key===k?(colSort.dir>0?' ▲':' ▼'):'';
const head='<th class="stick sortable" data-sortk="addr">Property'+arrow('addr')+'</th>'+cols.map(c=>`<th class="sortable" data-sortk="${c.k}">${esc(c.label)}${arrow(c.k)}</th>`).join('');
const body=rows.map(p=>`<tr class="lrow" data-id="${p.id}">`
- +`<td class="stick"><a class="la" href="${p.source}" target="_blank" rel="noopener noreferrer">${esc(p.address)}</a><div class="lc">${esc(p.city)}, CA ${p.zip||''}</div></td>`
+ +`<td class="stick"><button class="starbtn ${SHORT.has(p.id)?'on':''}" data-star="${p.id}" title="Shortlist">★</button> <a class="la" href="${p.source}" target="_blank" rel="noopener noreferrer">${esc(p.address)}</a><div class="lc">${esc(p.city)}, CA ${p.zip||''}</div></td>`
+cols.map(c=>`<td>${colCell(c.k,p)}</td>`).join('')+`</tr>`).join('');
return `<div class="listwrap"><table class="listtbl"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
}
@@ -700,7 +709,7 @@ function loopnetLink(p){
}
// Rich filter state
const F = { types:new Set(), firms:new Set(), cityQuery:'', priceMin:null, priceMax:null,
- capMin:0, unitsMin:null, yearMin:null, status:'all', afford:false, unwarrantable:false, nonqm:false };
+ capMin:0, unitsMin:null, yearMin:null, status:'all', afford:false, unwarrantable:false, nonqm:false, shortlist:false };
function recompute(){
const A = SCEN[scenario];
@@ -778,7 +787,10 @@ function card(p){
return `<div class="card ${uc?'uc':''}" id="card-${p.id}">
<div class="top">
<div><div class="rank">#${p.rank} · score</div><div class="addr">${p.address}</div><div class="city">${p.city}, CA ${p.zip||''}</div></div>
- ${vis('score')?`<div class="score">${p.composite}<small>fin ${p.financeScore}${p.qwenScore!=null?' · ai '+p.qwenScore:''}</small></div>`:''}
+ <div style="display:flex;align-items:flex-start;gap:8px">
+ <button class="starbtn ${SHORT.has(p.id)?'on':''}" data-star="${p.id}" title="Shortlist this listing">★</button>
+ ${vis('score')?`<div class="score">${p.composite}<small>fin ${p.financeScore}${p.qwenScore!=null?' · ai '+p.qwenScore:''}</small></div>`:''}
+ </div>
</div>
<div class="badges">${vis('deal')?dealBadge(p):''}${vis('firm')?firmB:''}${vis('type')?`<span class="b type">${p.type}</span>`:''}${vis('units')?`<span class="b">${p.units} unit${p.units>1?'s':''}</span>`:''}${vis('price')?`<span class="b">${fmt(p.price)}</span>`:''}${vis('ppu')&&ppu(p)&&!p.isCondo?`<span class="b" title="Price per unit">$${ppu(p).toLocaleString()}/unit</span>`:''}${vis('pricePerSf')&&ppsf(p)?`<span class="b" title="Price per building SF">$${ppsf(p).toLocaleString()}/sf</span>`:''}${vis('cap')?capB:''}${vis('verified')?verB:''}${vis('yield')&&f.grossYield?`<span class="b ok" title="Annual gross rent ÷ price">${f.grossYield}% rent yld</span>`:''}${vis('grm')&&grm(p)!=null?`<span class="b" title="Gross rent multiplier — price ÷ annual gross rent (lower = cheaper vs rent)">GRM ${grm(p)}</span>`:''}${vis('yearBuilt')&&p.year_built?`<span class="b" title="Year built">🏗 ${p.year_built}</span>`:''}${vis('dom')&&daysOnMarket(p)!=null?`<span class="b" title="Days since listed">${daysOnMarket(p)}d on mkt</span>`:''}${vis('beds')&&p.beds!=null?`<span class="b">${p.beds} bd</span>`:''}${vis('baths')&&p.baths!=null?`<span class="b">${p.baths} ba</span>`:''}${vis('sqft')&&p.sqft?`<span class="b">${(+p.sqft).toLocaleString()} sf</span>`:''}${vis('hoa')&&p.hoa?`<span class="b" title="HOA dues">$${(+p.hoa).toLocaleString()}/mo</span>`:''}${vis('warr')&&p.warrantable_status?`<span class="b ${p.warrantable_status==='fha_approved'?'ok':'warn'}" title="Condo warrantability (FHA-approval proxy)">${p.warrantable_status==='fha_approved'?'✓ warrantable':'⚑ '+p.warrantable_status}</span>`:''}${vis('broker')&&p.broker_name?`<span class="b" title="Listing broker">🧑💼 ${esc(p.broker_name)}</span>`:''}${vis('status')?statusB:''} ${vis('afford')?aff:''} ${vis('rec')?rec:''}</div>
${vis('brokerflag')?brokerFlagBadge:''}
@@ -862,6 +874,7 @@ function applyFilters(list){
if(F.afford && !p.finance.affordable) return false;
if(F.unwarrantable && !(p.isCondo && p.warrantable_status && p.warrantable_status!=='fha_approved')) return false;
if(F.nonqm && !isNonQM(p)) return false;
+ if(F.shortlist && !SHORT.has(p.id)) return false;
return true;
});
}
@@ -870,7 +883,7 @@ function activeFilterCount(){
if(F.types.size) n++; if(F.firms.size) n++; if(F.cityQuery) n++;
if(F.priceMin!=null||F.priceMax!=null) n++; if(F.capMin>0) n++;
if(F.unitsMin!=null) n++; if(F.yearMin!=null) n++;
- if(F.status!=='all') n++; if(F.afford) n++; if(F.unwarrantable) n++; if(F.nonqm) n++;
+ if(F.status!=='all') n++; if(F.afford) n++; if(F.unwarrantable) n++; if(F.nonqm) n++; if(F.shortlist) n++;
return n;
}
@@ -898,6 +911,7 @@ function renderActiveBar(){
if(F.afford) chips.push(['afford', 'in budget']);
if(F.unwarrantable) chips.push(['unwarrantable', '⚑ unwarrantable']);
if(F.nonqm) chips.push(['nonqm', '◆ non-QM']);
+ if(F.shortlist) chips.push(['shortlist', '⭐ shortlist']);
bar.innerHTML = chips.length
? '<span class="lbl">Filtering</span>'+chips.map(([k,l])=>`<span class="afchip" data-clear="${k.replace(/"/g,'"')}">${esc(l)} <span class="xx">✕</span></span>`).join('')+'<button class="afreset" id="afreset">Reset all ✕</button>'
: '';
@@ -915,6 +929,7 @@ function clearFilter(spec){
else if(spec==='afford'){ F.afford=false; if($('#fAfford')) $('#fAfford').checked=false; }
else if(spec==='unwarrantable'){ F.unwarrantable=false; }
else if(spec==='nonqm'){ F.nonqm=false; }
+ else if(spec==='shortlist'){ F.shortlist=false; }
syncChips(); render();
}
// ---- shareable URL state (every filter/sort/view/scenario ↔ query string) ----
@@ -973,6 +988,7 @@ function copyShareLink(){ const t=$('#linkbtn'); try{ navigator.clipboard.writeT
function render(){
const g=$('#grid'); const fc=activeFilterCount();
$('#fcount').textContent=fc; $('#fcount').classList.toggle('on',fc>0);
+ { const sb=$('#shortbtn'); if(sb){ sb.textContent='⭐ Shortlist ('+SHORT.size+')'; sb.classList.toggle('shorton',F.shortlist); } }
renderActiveBar(); stateToURL();
// Wait-for-selection: on a clean load (no filter chosen) don't dump 2,134 cards — prompt for a pick.
if(fc===0 && !F.cityQuery){
@@ -1231,7 +1247,8 @@ fetch('data/ranked.json').then(r=>r.json()).then(async d=>{
$('#examples').innerHTML = EX.map(x=>`<button class="ex">${x}</button>`).join('');
$('#examples').onclick=e=>{ if(e.target.classList.contains('ex')){ $('#q').value=e.target.textContent; $('#q').focus(); } };
$('#chatform').onsubmit=e=>{ e.preventDefault(); const t=$('#q').value.trim(); if(!t) return; $('#q').value=''; ask(t); };
- $('#grid').onclick=e=>{ 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); };
+ $('#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(); };
window.DATA_READY = true;
recompute(); syncChips(); render();
← c919ae1 feat(index): ⬇ CSV export of the current filtered+sorted vie
·
back to Commercialrealestate
·
feat(index): aggregate stats strip — median price / $-per-un d9c099f →