[object Object]

← back to Rentv 2026

feat(deals): add sort + density controls to the CRE deal grid (HARD-RULE compliance)

fccef3ba352775480239ffb05713fe2e2782c09b · 2026-07-31 15:55:25 -0700 · Steve Abrams

Steve's standing rule — every customer-facing data grid gets a sort <select> +
a density slider, both persisted to localStorage. deals.html rendered /api/deals
cards with txn/type pills + text search but had neither. Added:
- Sort: Newest (natural feed order) · Price ↓/↑ (parses $12.5M-style labels,
  unpriced sort last) · Property Type / Location / Title A→Z
- Density: 2–5 columns via --cols
Verified: sort logic monotonic-correct on the live 30-deal feed; density + both
localStorage keys proven in a headless browser; inline JS parses; no new console
errors (only pre-existing favicon 404).

Files touched

Diff

commit fccef3ba352775480239ffb05713fe2e2782c09b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jul 31 15:55:25 2026 -0700

    feat(deals): add sort + density controls to the CRE deal grid (HARD-RULE compliance)
    
    Steve's standing rule — every customer-facing data grid gets a sort <select> +
    a density slider, both persisted to localStorage. deals.html rendered /api/deals
    cards with txn/type pills + text search but had neither. Added:
    - Sort: Newest (natural feed order) · Price ↓/↑ (parses $12.5M-style labels,
      unpriced sort last) · Property Type / Location / Title A→Z
    - Density: 2–5 columns via --cols
    Verified: sort logic monotonic-correct on the live 30-deal feed; density + both
    localStorage keys proven in a headless browser; inline JS parses; no new console
    errors (only pre-existing favicon 404).
---
 public/deals.html | 58 +++++++++++++++++++++++++++++++++++++++++++++++++------
 1 file changed, 52 insertions(+), 6 deletions(-)

diff --git a/public/deals.html b/public/deals.html
index 66c8ac93..9d326287 100644
--- a/public/deals.html
+++ b/public/deals.html
@@ -33,9 +33,12 @@
   .controls{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:18px 0 6px}
   .pill{font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:7px 13px;border:1px solid var(--line);border-radius:999px;background:#fff;cursor:pointer;color:var(--sub)}
   .pill.on{background:var(--ink);color:#fff;border-color:var(--ink)}
-  .controls input{margin-left:auto;font-family:var(--sans);font-size:13px;padding:8px 12px;border:1px solid var(--line);border-radius:6px;min-width:200px}
+  .controls input[type=search]{margin-left:auto;font-family:var(--sans);font-size:13px;padding:8px 12px;border:1px solid var(--line);border-radius:6px;min-width:200px}
+  .ctl{display:inline-flex;align-items:center;gap:7px;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--sub)}
+  .ctl select{font-family:var(--sans);font-size:13px;font-weight:600;text-transform:none;letter-spacing:0;color:var(--ink);padding:7px 10px;border:1px solid var(--line);border-radius:6px;background:#fff;cursor:pointer}
+  .ctl.dens input[type=range]{width:110px;accent-color:var(--red);cursor:pointer}
   .count{font-size:12px;color:var(--sub);margin:6px 0 16px}
-  .grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
+  .grid{display:grid;grid-template-columns:repeat(var(--cols,3),1fr);gap:16px}
   .card{background:#fff;border:1px solid var(--line);border-radius:10px;overflow:hidden;display:flex;flex-direction:column;transition:box-shadow .15s,transform .15s}
   .card:hover{box-shadow:0 8px 24px rgba(0,0,0,.09);transform:translateY(-2px)}
   .card .ph{aspect-ratio:16/9;background:var(--wash) center/cover no-repeat;position:relative}
@@ -53,7 +56,7 @@
   footer{background:var(--ink);color:#8b939b;margin-top:40px;padding:26px 0;font-size:12px}
   footer .wrap{display:flex;justify-content:space-between;flex-wrap:wrap;gap:10px}
   @media(max-width:900px){.grid{grid-template-columns:1fr 1fr}}
-  @media(max-width:600px){.grid{grid-template-columns:1fr}.controls input{margin-left:0;width:100%}}
+  @media(max-width:600px){.grid{grid-template-columns:1fr!important}.controls input[type=search]{margin-left:0;width:100%}}
 </style>
 </head>
 <body>
@@ -75,26 +78,62 @@
     </div>
     <div class="controls" id="txns"></div>
     <div class="controls" id="types"></div>
-    <div class="controls"><input id="q" type="search" placeholder="Search city, property, party…" autocomplete="off"></div>
+    <div class="controls">
+      <label class="ctl"><span>Sort</span>
+        <select id="sort">
+          <option value="newest">Newest</option>
+          <option value="price-desc">Price ↓</option>
+          <option value="price-asc">Price ↑</option>
+          <option value="type">Property Type A→Z</option>
+          <option value="loc">Location A→Z</option>
+          <option value="title">Title A→Z</option>
+        </select>
+      </label>
+      <label class="ctl dens"><span>Density</span>
+        <input id="dens" type="range" min="2" max="5" step="1" value="3" aria-label="Grid density (columns)">
+      </label>
+      <input id="q" type="search" placeholder="Search city, property, party…" autocomplete="off">
+    </div>
     <div class="count" id="count"></div>
     <div class="grid" id="grid"></div>
   </main>
   <footer><div class="wrap"><span>© RENTV.com — Commercial Real Estate News</span><span>Deal listings parsed from live RENTV.com reporting, refreshed every 20 min</span></div></footer>
 <script>
 const $=s=>document.querySelector(s);
-let ALL=[], fTxn='', fType='', fQ='';
+let ALL=[], fTxn='', fType='', fQ='', sVal=(localStorage.getItem('rentv.deals.sort')||'newest');
 const TXNS=['Sale','Lease','Financing','Development'];
 const TYPES=['Multifamily','Office','Industrial','Retail','Hospitality','Mixed-Use','Land'];
 function esc(s){return (s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
+// parse "$12.5M" / "1,250,000" → number (‑1 = no parseable amount, sorts last)
+function amtNum(d){
+  const s=String(d.amount_label||d.amount||'').replace(/[,\s$]/g,'');
+  const m=s.match(/([\d.]+)\s*([KMB])?/i);
+  if(!m) return -1;
+  let n=parseFloat(m[1]); if(isNaN(n)) return -1;
+  const u=(m[2]||'').toUpperCase();
+  if(u==='K')n*=1e3; else if(u==='M')n*=1e6; else if(u==='B')n*=1e9;
+  return n;
+}
+function sortRows(rows){
+  const r=rows.slice();
+  if(sVal==='price-desc') r.sort((a,b)=>amtNum(b)-amtNum(a));
+  else if(sVal==='price-asc') r.sort((a,b)=>{const x=amtNum(a),y=amtNum(b);if(x<0)return 1;if(y<0)return -1;return x-y;});
+  else if(sVal==='type') r.sort((a,b)=>(a.property_type||'').localeCompare(b.property_type||''));
+  else if(sVal==='loc') r.sort((a,b)=>[a.city,a.state].filter(Boolean).join(', ').localeCompare([b.city,b.state].filter(Boolean).join(', ')));
+  else if(sVal==='title') r.sort((a,b)=>(a.title||'').localeCompare(b.title||''));
+  // 'newest' = natural feed order (ALL is already newest-first)
+  return r;
+}
 function pills(host,list,cur,pick){
   host.innerHTML=`<button class="pill${cur===''?' on':''}" data-v="">All</button>`+
     list.map(v=>`<button class="pill${cur===v?' on':''}" data-v="${v}">${v}</button>`).join('');
   host.querySelectorAll('.pill').forEach(b=>b.onclick=()=>{pick(b.dataset.v);});
 }
 function render(){
-  const rows=ALL.filter(d=>
+  let rows=ALL.filter(d=>
     (!fTxn||d.txn_type===fTxn)&&(!fType||d.property_type===fType)&&
     (!fQ||`${d.title} ${d.city} ${d.state} ${d.summary}`.toLowerCase().includes(fQ)));
+  rows=sortRows(rows);
   $('#count').textContent=`${rows.length} listing${rows.length===1?'':'s'}${fTxn?' · '+fTxn:''}${fType?' · '+fType:''}`;
   $('#grid').innerHTML=rows.length?rows.map(d=>{
     const loc=[d.city,d.state].filter(Boolean).join(', ');
@@ -124,6 +163,13 @@ function render(){
   pills($('#txns'),TXNS,fTxn,pickTxn);
   pills($('#types'),TYPES,fType,pickType);
   $('#q').oninput=e=>{fQ=e.target.value.trim().toLowerCase();render();};
+  // sort + density, persisted (HARD RULE: every data grid gets both)
+  const selSort=$('#sort'), selDens=$('#dens');
+  selSort.value=sVal;
+  selSort.onchange=()=>{sVal=selSort.value;localStorage.setItem('rentv.deals.sort',sVal);render();};
+  const cols0=localStorage.getItem('rentv.deals.cols')||'3';
+  selDens.value=cols0; document.documentElement.style.setProperty('--cols',cols0);
+  selDens.oninput=()=>{document.documentElement.style.setProperty('--cols',selDens.value);localStorage.setItem('rentv.deals.cols',selDens.value);};
   render();
 })();
 </script>

← 7f6b537b fix(social): harden Vimeo refresh — empty-result guard + ato  ·  back to Rentv 2026  ·  auto-save: 2026-07-31T16:29:30 (1 files) — public/watchlist. b4f52715 →