[object Object]

← back to Commercialrealestate

just-listed: card grid → sortable, removable-column list view

2ff697152e60b3d2de5c4bcdb026bf8e9e7209d7 · 2026-08-11 13:39:35 -0700 · Steve Abrams

Files touched

Diff

commit 2ff697152e60b3d2de5c4bcdb026bf8e9e7209d7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Aug 11 13:39:35 2026 -0700

    just-listed: card grid → sortable, removable-column list view
---
 public/just-listed.html | 168 ++++++++++++++++++++++++++++++++++++------------
 1 file changed, 127 insertions(+), 41 deletions(-)

diff --git a/public/just-listed.html b/public/just-listed.html
index 035b5d8..bf804b3 100644
--- a/public/just-listed.html
+++ b/public/just-listed.html
@@ -2,7 +2,7 @@
 <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
 <title>Just Listed — Active Inventory · CRCP</title>
 <style>
-:root{--bg:#0e1116;--card:#161b22;--line:#2a313c;--ink:#e6edf3;--mut:#8b949e;--acc:#3fb950;--blue:#58a6ff;--gold:#ffa600;--violet:#bc8cff;--cols:4}
+:root{--bg:#0e1116;--card:#161b22;--line:#2a313c;--ink:#e6edf3;--mut:#8b949e;--acc:#3fb950;--blue:#58a6ff;--gold:#ffa600;--violet:#bc8cff;--rowpad:6px}
 *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font-family:-apple-system,Helvetica,Arial,sans-serif;font-size:13px}
 header{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;flex-wrap:wrap;gap:10px;align-items:center}
 h1{font-size:17px;margin:0}h1 b{color:#fff}
@@ -35,24 +35,41 @@ h1{font-size:17px;margin:0}h1 b{color:#fff}
 .toolbar{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:10px}
 .search{flex:1;min-width:200px;background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:8px 11px;font-size:13px}
 select{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:7px 9px;font-size:12px}
+.btn{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:7px 11px;font-size:12px;cursor:pointer;white-space:nowrap}
+.btn:hover{border-color:var(--blue)}
 .count{color:var(--mut);font-size:12px}
 .prov{font-size:11px;color:var(--mut);margin:0 0 12px;line-height:1.5}
 .stat{display:inline-block;background:var(--card);border:1px solid var(--line);border-radius:9px;padding:7px 12px;margin:0 8px 10px 0}
 .stat b{font-size:18px;color:#fff}.stat span{display:block;font-size:10px;text-transform:uppercase;color:var(--mut);letter-spacing:.4px}
-.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:12px}
-.lcard{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:13px 14px;display:flex;flex-direction:column;gap:7px}
-.lcard .top{display:flex;justify-content:space-between;align-items:flex-start;gap:8px}
-.lcard .name{font-weight:600;font-size:13.5px;line-height:1.25}
-.lcard .price{font-size:20px;font-weight:700;color:var(--gold);font-variant-numeric:tabular-nums}
-.st{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;padding:2px 6px;border-radius:6px;white-space:nowrap;flex-shrink:0}
+.st{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;padding:2px 6px;border-radius:6px;white-space:nowrap;display:inline-block}
 .st.active{background:rgba(63,185,80,.16);color:var(--acc)}
 .st.pending{background:rgba(255,166,0,.16);color:var(--gold)}
 .st.other{background:rgba(139,148,158,.16);color:var(--mut)}
-.dl{display:flex;flex-wrap:wrap;gap:5px 14px;font-size:12px;color:var(--ink)}
-.dl .i{color:var(--mut);display:inline-flex;align-items:center;gap:4px}
-.dl .i b{color:var(--ink);font-weight:600}
-.up{font-size:11.5px;color:var(--mut);line-height:1.45;border-top:1px solid #20262f;padding-top:6px}
-.lcard a.f{color:var(--blue);text-decoration:none;font-size:12px}
+/* ── column-picker dropdown ── */
+.colmenu{position:relative}
+.colpanel{position:absolute;right:0;top:calc(100% + 4px);background:var(--card);border:1px solid var(--line);border-radius:10px;padding:9px 10px;z-index:30;display:grid;grid-template-columns:1fr 1fr;gap:1px 16px;box-shadow:0 10px 30px rgba(0,0,0,.5);min-width:300px}
+.colpanel .ph{grid-column:1/-1;font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);margin:0 0 4px;display:flex;justify-content:space-between;align-items:center}
+.colpanel .ph a{color:var(--blue);cursor:pointer;text-transform:none;letter-spacing:0}
+.colpanel label{display:flex;align-items:center;gap:7px;font-size:12px;padding:3px 3px;cursor:pointer;white-space:nowrap;color:var(--ink);border-radius:6px}
+.colpanel label:hover{background:rgba(88,166,255,.08)}
+.colpanel input{accent-color:var(--blue)}
+/* ── the list / table ── */
+.tablewrap{overflow:auto;border:1px solid var(--line);border-radius:12px;background:var(--card);max-height:calc(100vh - 300px)}
+table{border-collapse:collapse;width:100%;font-size:12.5px}
+thead th{position:sticky;top:0;background:#11161d;z-index:2;text-align:left;padding:9px 11px;border-bottom:1px solid var(--line);white-space:nowrap;cursor:pointer;user-select:none;color:var(--mut);font-weight:700;font-size:10.5px;text-transform:uppercase;letter-spacing:.4px}
+thead th:hover{color:var(--ink)}
+thead th.sorted{color:var(--blue)}
+thead th .ar{opacity:.35;font-size:9px;margin-left:4px}
+thead th.sorted .ar{opacity:1}
+thead th.num{text-align:right}
+tbody td{padding:var(--rowpad) 11px;border-bottom:1px solid #20262f;vertical-align:top;line-height:1.35}
+tbody td.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
+tbody tr:hover{background:rgba(88,166,255,.06)}
+td.price{color:var(--gold);font-weight:600}
+td.wrap{white-space:normal;min-width:220px;max-width:380px;color:var(--mut)}
+td a.f{color:var(--blue);text-decoration:none}
+td a.f:hover{text-decoration:underline}
+.empty{color:var(--mut);padding:40px;text-align:center}
 </style></head><body>
 <header>
   <h1><b>Just Listed</b> · Active Inventory</h1>
@@ -76,22 +93,58 @@ select{background:var(--card);border:1px solid var(--line);color:var(--ink);bord
   <div class="toolbar">
     <input class="search" id="q" placeholder="search address, city, type, firm, upside…  (space = AND)">
     <select id="sort"></select>
-    <label style="font-size:11px;color:var(--mut)">density <input type="range" id="dens" min="2" max="7" step="1"></label>
+    <div class="colmenu">
+      <button class="btn" id="colBtn" aria-expanded="false">⚙ Columns ▾</button>
+      <div class="colpanel" id="colPanel" hidden></div>
+    </div>
+    <label style="font-size:11px;color:var(--mut)">row height <input type="range" id="dens" min="2" max="10" step="1"></label>
     <span class="count" id="count"></span>
   </div>
-  <div class="grid" id="grid"></div>
+  <div class="tablewrap">
+    <table>
+      <thead id="thead"></thead>
+      <tbody id="tbody"></tbody>
+    </table>
+  </div>
+  <div class="empty" id="empty" hidden>No listings match.</div>
 </div></div>
 <script>
 const $=s=>document.querySelector(s);
 const esc=s=>(s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;');
 const money=n=>n==null||isNaN(+n)?null:'$'+Math.round(+n).toLocaleString();
 let DATA=[],META={},q='',sortKey='price',sortDir=-1,ME=null;
-let dens=(function(){const v=parseInt(localStorage.getItem('jlDens'),10);return(v>=2&&v<=7)?v:4;})();
+let dens=(function(){const v=parseInt(localStorage.getItem('jlDens'),10);return(v>=2&&v<=10)?v:6;})();
 const F={status:new Set(),type:new Set(),city:new Set(),pMin:null,pMax:null,uMin:null};
 const tog=(s,v)=>{s.has(v)?s.delete(v):s.add(v);};
 const statusClass=s=>{const t=String(s||'').toLowerCase();return t.startsWith('active')?'active':(t.includes('pending')||t.includes('contract'))?'pending':'other';};
 const statusShort=s=>String(s||'—').replace(/\s*\(.*\)$/,'').trim();
 
+// ── COLUMN MODEL — one source of truth for header, cells, sort comparator + picker ──
+// num:true → right-aligned + numeric sort.  val() → custom sort/derived value.  render() → cell HTML.  on → visible by default.
+const COLS=[
+  {k:'address',label:'Address',on:true,render:r=>r.source&&/^https?:/.test(r.source)?`<a class="f" href="${esc(r.source)}" target="_blank" rel="noopener noreferrer">${esc(r.address||'—')} ↗</a>`:esc(r.address||'—')},
+  {k:'city',label:'City',on:true},
+  {k:'zip',label:'ZIP',on:false},
+  {k:'type',label:'Type',on:true},
+  {k:'price',label:'Price',on:true,num:true,cls:'price',render:r=>money(r.price)||'—'},
+  {k:'units',label:'Units',on:true,num:true},
+  {k:'ppu',label:'$/Unit',on:true,num:true,val:r=>(r.price&&r.units)?Math.round(r.price/r.units):null,render:r=>{const v=(r.price&&r.units)?Math.round(r.price/r.units):null;return v?money(v):'—';}},
+  {k:'cap_rate',label:'Cap %',on:true,num:true,render:r=>(r.cap_rate!=null&&r.cap_rate!=='')?(+r.cap_rate).toFixed(2)+'%':'—'},
+  {k:'sqft',label:'Sq Ft',on:false,num:true,render:r=>r.sqft?(+r.sqft).toLocaleString():'—'},
+  {k:'year_built',label:'Built',on:false,num:true,render:r=>r.year_built||'—'},
+  {k:'status',label:'Status',on:true,render:r=>`<span class="st ${statusClass(r.status)}">${esc(statusShort(r.status))}</span>`},
+  {k:'rent_control',label:'Rent control',on:false,cls:'wrap',render:r=>esc(r.rent_control||'—')},
+  {k:'firm',label:'Firm',on:false,render:r=>esc(String(r.firm||'—').slice(0,42))},
+  {k:'metro',label:'Metro',on:false,render:r=>esc(r.metro||'—')},
+  {k:'verified',label:'Verified',on:false,render:r=>r.verified?'✓':'—'},
+  {k:'upside_note',label:'Upside',on:true,cls:'wrap',render:r=>esc(String(r.upside_note||'—').slice(0,200))},
+];
+// restore saved column visibility
+try{const saved=JSON.parse(localStorage.getItem('jlCols')||'null');if(Array.isArray(saved)&&saved.length)COLS.forEach(c=>c.on=saved.includes(c.k));}catch(e){}
+const visCols=()=>COLS.filter(c=>c.on);
+const sortVal=(r,key)=>{const c=COLS.find(x=>x.k===key);return c&&c.val?c.val(r):r[key];};
+const saveCols=()=>localStorage.setItem('jlCols',JSON.stringify(COLS.filter(c=>c.on).map(c=>c.k)));
+
 // space-separated search = AND across every field (Steve standing rule)
 function passSearch(r){
   if(!q)return true;
@@ -109,7 +162,7 @@ function pass(r){
 }
 function filtered(){
   let rows=DATA.filter(pass);
-  rows=rows.slice().sort((a,b)=>{let x=a[sortKey],y=b[sortKey];const xm=(x==null||x===''),ym=(y==null||y==='');
+  rows=rows.slice().sort((a,b)=>{let x=sortVal(a,sortKey),y=sortVal(b,sortKey);const xm=(x==null||x===''),ym=(y==null||y==='');
     if(xm&&ym)return 0;if(xm)return 1;if(ym)return -1;
     if(!isNaN(+x)&&!isNaN(+y)){x=+x;y=+y;}else{x=String(x).toLowerCase();y=String(y).toLowerCase();}
     return x<y?-1*sortDir:x>y?sortDir:0;});
@@ -121,27 +174,23 @@ function buildRail(){
   DATA.forEach(r=>{const s=statusShort(r.status);sc[s]=(sc[s]||0)+1;const t=r.type||'—';tc[t]=(tc[t]||0)+1;const c=r.city||'—';cc[c]=(cc[c]||0)+1;});
   $('#fStatus').innerHTML=chip(sc,'s');$('#fType').innerHTML=chip(tc,'t',10);$('#fCity').innerHTML=chip(cc,'c',18);
 }
-function card(r){
-  const price=money(r.price);
-  const details=[];
-  if(r.units) details.push(`<span class="i">🏢 <b>${r.units}</b> units</span>`);
-  if(r.cap_rate) details.push(`<span class="i">📈 <b>${(+r.cap_rate).toFixed(2)}%</b> cap</span>`);
-  if(r.sqft) details.push(`<span class="i">📐 ${(+r.sqft).toLocaleString()} sf</span>`);
-  if(r.year_built) details.push(`<span class="i">🏗️ ${r.year_built}</span>`);
-  if(r.city) details.push(`<span class="i">📍 ${esc(r.city)}${r.zip?' '+esc(r.zip):''}</span>`);
-  if(r.type) details.push(`<span class="i">🏷️ ${esc(r.type)}</span>`);
-  const link=r.source&&/^https?:/.test(r.source)?`<a class="f" href="${esc(r.source)}" target="_blank" rel="noopener noreferrer">🔗 view listing ↗</a>`:'';
-  const firm=r.firm?`<span class="i">🏛️ ${esc(String(r.firm).slice(0,30))}</span>`:'';
-  return `<div class="lcard">
-    <div class="top"><div class="name">${esc(r.address||'—')}</div><span class="st ${statusClass(r.status)}">${esc(statusShort(r.status))}</span></div>
-    ${price?`<div class="price">${price}</div>`:''}
-    <div class="dl">${details.join('')}${firm}</div>
-    ${r.upside_note?`<div class="up">${esc(String(r.upside_note).slice(0,220))}</div>`:''}
-    ${link}
-  </div>`;
+// ── header (click to sort) ──
+function renderHead(){
+  $('#thead').innerHTML='<tr>'+visCols().map(c=>{
+    const sorted=c.k===sortKey,ar=sorted?(sortDir===1?'▲':'▼'):'↕';
+    return `<th data-k="${c.k}" class="${c.num?'num':''}${sorted?' sorted':''}" title="Sort by ${esc(c.label)}">${esc(c.label)}<span class="ar">${ar}</span></th>`;
+  }).join('')+'</tr>';
+}
+// ── body ──
+function rowHtml(r){
+  return '<tr>'+visCols().map(c=>{
+    const cls=((c.num?'num ':'')+(c.cls||'')).trim();
+    const content=c.render?c.render(r):(r[c.k]==null||r[c.k]===''?'—':esc(r[c.k]));
+    return `<td${cls?` class="${cls}"`:''}>${content}</td>`;
+  }).join('')+'</tr>';
 }
 function render(){
-  document.documentElement.style.setProperty('--cols',dens);
+  document.documentElement.style.setProperty('--rowpad',dens+'px');
   const rows=filtered();
   const tot=rows.reduce((s,r)=>s+(+r.price||0),0);
   const units=rows.reduce((s,r)=>s+(+r.units||0),0);
@@ -150,9 +199,27 @@ function render(){
     `<div class="stat"><b style="color:var(--gold)">$${(tot/1e9).toFixed(2)}B</b><span>total ask</span></div>`+
     `<div class="stat"><b>${units.toLocaleString()}</b><span>units</span></div>`+
     `<div class="stat"><b>${new Set(rows.map(r=>r.city)).size}</b><span>cities</span></div>`;
-  $('#count').textContent=`${rows.length.toLocaleString()} of ${DATA.length.toLocaleString()}`;
-  $('#grid').innerHTML=rows.slice(0,600).map(card).join('')||'<div style="color:var(--mut);padding:40px">No listings match.</div>';
+  const shown=rows.slice(0,600);
+  $('#count').textContent=`${rows.length.toLocaleString()} of ${DATA.length.toLocaleString()}`+(rows.length>600?' (showing first 600)':'');
+  renderHead();
+  $('#tbody').innerHTML=shown.map(rowHtml).join('');
+  $('#empty').hidden=rows.length>0;
 }
+// ── set sort + keep the preset <select> in sync ──
+function setSort(key,dir){
+  sortKey=key;sortDir=dir;
+  const want=key+':'+(dir===1?'asc':'desc');
+  if([...$('#sort').options].some(o=>o.value===want))$('#sort').value=want;
+  render();
+}
+// header click: same col flips direction; new col defaults desc for numbers, asc for text
+$('#thead').addEventListener('click',e=>{
+  const th=e.target.closest('th');if(!th)return;
+  const key=th.dataset.k,col=COLS.find(c=>c.k===key);
+  if(key===sortKey)setSort(key,-sortDir);
+  else setSort(key,col&&col.num?-1:1);
+});
+// rail filters
 $('#fStatus').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.status,c.dataset.s);c.classList.toggle('active');render();});
 $('#fType').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.type,c.dataset.t);c.classList.toggle('active');render();});
 $('#fCity').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.city,c.dataset.c);c.classList.toggle('active');render();});
@@ -160,13 +227,32 @@ $('#pMin').addEventListener('input',()=>{F.pMin=$('#pMin').value?+$('#pMin').val
 $('#pMax').addEventListener('input',()=>{F.pMax=$('#pMax').value?+$('#pMax').value:null;render();});
 $('#uMin').addEventListener('input',()=>{F.uMin=$('#uMin').value?+$('#uMin').value:null;render();});
 $('#q').addEventListener('input',()=>{q=$('#q').value.trim();render();});
-$('#sort').addEventListener('change',()=>{const v=$('#sort').value.split(':');sortKey=v[0];sortDir=v[1]==='asc'?1:-1;render();});
+$('#sort').addEventListener('change',()=>{const v=$('#sort').value.split(':');setSort(v[0],v[1]==='asc'?1:-1);});
 $('#dens').addEventListener('input',()=>{dens=+$('#dens').value;localStorage.setItem('jlDens',dens);render();});
-$('#sort').innerHTML=[['price:desc','Price ↓'],['price:asc','Price ↑'],['units:desc','Units ↓'],['cap_rate:desc','Cap rate ↓'],['year_built:desc','Newest built'],['city:asc','City A→Z'],['address:asc','Address A→Z']].map(([k,l])=>`<option value="${k}"${k==='price:desc'?' selected':''}>Sort: ${l}</option>`).join('');
+$('#sort').innerHTML=[['price:desc','Price ↓'],['price:asc','Price ↑'],['units:desc','Units ↓'],['ppu:asc','$/Unit ↑'],['cap_rate:desc','Cap rate ↓'],['year_built:desc','Newest built'],['city:asc','City A→Z'],['address:asc','Address A→Z']].map(([k,l])=>`<option value="${k}"${k==='price:desc'?' selected':''}>Sort: ${l}</option>`).join('');
 $('#dens').value=dens;
+// ── column picker (remove / restore any column) ──
+function renderColPanel(){
+  $('#colPanel').innerHTML=
+    `<div class="ph"><span>Columns shown</span><a id="colAll">all</a></div>`+
+    COLS.map(c=>`<label><input type="checkbox" data-k="${c.k}"${c.on?' checked':''}>${esc(c.label)}</label>`).join('');
+}
+renderColPanel();
+$('#colBtn').addEventListener('click',e=>{e.stopPropagation();const p=$('#colPanel');p.hidden=!p.hidden;$('#colBtn').setAttribute('aria-expanded',String(!p.hidden));});
+$('#colPanel').addEventListener('click',e=>{
+  e.stopPropagation();
+  if(e.target.id==='colAll'){COLS.forEach(c=>c.on=true);renderColPanel();saveCols();render();return;}
+  const cb=e.target.closest('input');if(!cb)return;
+  const col=COLS.find(c=>c.k===cb.dataset.k);if(!col)return;
+  col.on=cb.checked;
+  if(!visCols().length){col.on=true;cb.checked=true;return;} // never allow zero columns
+  if(sortKey===col.k&&!col.on){setSort(visCols()[0].k,-1);} // if we hid the sorted col, re-anchor sort
+  saveCols();render();
+});
+document.addEventListener('click',()=>{$('#colPanel').hidden=true;$('#colBtn').setAttribute('aria-expanded','false');});
 // ── auth pill (same account layer as deals-flow) ──
 const api=(u,o)=>fetch(u,Object.assign({headers:{'Content-Type':'application/json'}},o||{})).then(r=>r.json());
-function renderAuth(){const el=$('#auth');if(ME&&ME.email){const who=esc(ME.name||ME.username||ME.email);const rl=ME.role?'':'';el.innerHTML=`<b style="color:#fff">${who}</b> · <a style="color:var(--blue);cursor:pointer">sign out</a>`;el.querySelector('a').onclick=async()=>{await fetch('/auth/logout',{method:'POST'});location.reload();};el.onclick=null;}else{el.textContent='Sign in';el.onclick=()=>location.href='/deals-flow.html';}}
+function renderAuth(){const el=$('#auth');if(ME&&ME.email){const who=esc(ME.name||ME.username||ME.email);el.innerHTML=`<b style="color:#fff">${who}</b> · <a style="color:var(--blue);cursor:pointer">sign out</a>`;el.querySelector('a').onclick=async()=>{await fetch('/auth/logout',{method:'POST'});location.reload();};el.onclick=null;}else{el.textContent='Sign in';el.onclick=()=>location.href='/deals-flow.html';}}
 api('/api/me').then(m=>{ME=m;renderAuth();}).catch(()=>{});
 // ── breaking-news ticker ──
 async function loadTicker(){try{const r=await api('/api/ticker');const items=(r&&r.items)||[];if(!items.length)return;const html=items.map(i=>`<span class="it"><span class="t ${i.kind}">${esc(i.tag)}</span>${esc(i.text)}</span>`).join('');const dup=html.replace(/<span class="it">/g,'<span class="it" aria-hidden="true">');$('#tickTrack').innerHTML=html+dup;$('#ticker').style.display='';}catch(e){}}
@@ -176,7 +262,7 @@ fetch('/data/listings.json').then(r=>r.json()).then(d=>{
   META=d.meta||{};
   DATA=(d.listings||[]).filter(l=>String(l.status||'').toLowerCase().startsWith('active'));
   $('#srcBadge').textContent=`${DATA.length.toLocaleString()} active`;
-  $('#prov').innerHTML=`Active listings sourced from broker/portal feeds${META.sourced?` · batch sourced <b>${esc(String(META.sourced).slice(0,10))}</b>`:''}. This feed carries no per-listing "date listed", so there's no true recency sort — use <b>Cap rate</b>, <b>Price</b>, or <b>Newest built</b>. Verify status against the source before acting.`;
+  $('#prov').innerHTML=`Active listings sourced from broker/portal feeds${META.sourced?` · batch sourced <b>${esc(String(META.sourced).slice(0,10))}</b>`:''}. Click any column header to sort; use <b>⚙ Columns</b> to show/hide fields. This feed carries no per-listing "date listed", so there's no true recency sort — use <b>Cap rate</b>, <b>Price</b>, or <b>Newest built</b>. Verify status against the source before acting.`;
   buildRail();render();
 }).catch(()=>{$('#count').textContent='failed to load /data/listings.json';});
 </script>  <script src="/global-search.js" defer></script>

← 9250ac0 afternoon CRE update 2026-08-11  ·  back to Commercialrealestate  ·  auto-data-snapshot: 2026-08-11T15:24:21 (2 data files) — dat 0b7153f →