[object Object]

← 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,'&quot;')}">${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 →