[object Object]

← back to Commercialrealestate

sales: Amazon rail + field toggles + grid/list via crcp-grid engine; add ?limit to closed-sales; engine link+cap support

adc85c120f0c1c8e8ee8b5ce12fbe730f4d2ce57 · 2026-07-04 13:26:56 -0700 · steve@designerwallcoverings.com

Files touched

Diff

commit adc85c120f0c1c8e8ee8b5ce12fbe730f4d2ce57
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date:   Sat Jul 4 13:26:56 2026 -0700

    sales: Amazon rail + field toggles + grid/list via crcp-grid engine; add ?limit to closed-sales; engine link+cap support
---
 public/crcp-grid.js |   6 ++-
 public/sales.html   | 106 +++++++++++++++++++++++++++++-----------------------
 scripts/serve.js    |   3 +-
 3 files changed, 65 insertions(+), 50 deletions(-)

diff --git a/public/crcp-grid.js b/public/crcp-grid.js
index 321499f..1bfc75d 100644
--- a/public/crcp-grid.js
+++ b/public/crcp-grid.js
@@ -130,8 +130,9 @@
       return rows.map(r => {
         const t = O.title ? O.title(r) : { name: r.name || '', sub: '' };
         const rc = O.onRow ? (O.onRow(r).cls || '') : '';
+        const nm = t.href ? `<a href="${esc(t.href)}" target="_blank" rel="noopener noreferrer" style="color:inherit">${esc(t.name)}</a>` : esc(t.name);
         return `<div class="cg-card ${rc}">` +
-          `<div class="cg-name">${esc(t.name)}</div>${t.sub ? `<div class="cg-sub">${esc(t.sub)}</div>` : ''}` +
+          `<div class="cg-name">${nm}</div>${t.sub ? `<div class="cg-sub">${esc(t.sub)}</div>` : ''}` +
           (O.badgeHtml ? O.badgeHtml(r) : '') +
           vis.map(f => `<div class="cg-r"><span class="cg-k">${esc(f.l)}</span><span>${fmt(fval(r, f), f)}</span></div>`).join('') +
           `</div>`;
@@ -143,7 +144,8 @@
       const body = rows.map(r => {
         const t = O.title ? O.title(r) : { name: '', sub: '' };
         const rc = O.onRow ? (O.onRow(r).cls || '') : '';
-        return `<tr class="${rc}"><td class="cg-stick"><span class="cg-la">${esc(t.name)}</span>${t.sub ? `<div class="cg-lc">${esc(t.sub)}</div>` : ''}</td>` +
+        const nm = t.href ? `<a href="${esc(t.href)}" target="_blank" rel="noopener noreferrer" style="color:inherit">${esc(t.name)}</a>` : esc(t.name);
+        return `<tr class="${rc}"><td class="cg-stick"><span class="cg-la">${nm}</span>${t.sub ? `<div class="cg-lc">${esc(t.sub)}</div>` : ''}</td>` +
           cols.map(f => `<td>${fmt(fval(r, f), f)}</td>`).join('') + `</tr>`;
       }).join('');
       return `<div class="cg-listwrap"><table class="cg-tbl"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
diff --git a/public/sales.html b/public/sales.html
index 6350fd1..d7b9230 100644
--- a/public/sales.html
+++ b/public/sales.html
@@ -5,26 +5,25 @@
 <meta name="viewport" content="width=device-width, initial-scale=1">
 <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%230a0d13'/%3E%3Ctext x='8' y='12' font-size='11' text-anchor='middle' fill='%23c8a24b'%3E%24%3C/text%3E%3C/svg%3E">
 <title>Closed Sales — LA Condo Comps</title>
+<link rel="stylesheet" href="/crcp-grid.css">
 <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
+<script src="/crcp-grid.js"></script>
 <style>
-  :root{ --bg:#0c0f15; --panel:#141925; --fg:#E6E8EC; --muted:#8A93A2; --gold:#C8A24B; --active:#70AD47; --draft:#5B9BD5; --line:#232a38; }
+  :root{ --bg:#0c0f15; --panel:#141925; --fg:#E6E8EC; --muted:#8A93A2; --gold:#C8A24B; --active:#70AD47; --draft:#5B9BD5; --line:#232a38;
+    /* crcp-grid engine theme mapping (gold/panel) */
+    --cg-line:#232a38; --cg-mut:#8A93A2; --cg-ink:#E6E8EC; --cg-card:#141925;
+    --cg-acc:#C8A24B; --cg-accbg:rgba(200,162,75,.12); --cg-railbg:#0b0e13; --cg-topbg:rgba(12,15,21,.95); --cg-red:#f85149; }
   *{box-sizing:border-box;} body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;}
-  header{display:flex;align-items:center;gap:14px;padding:14px 22px;border-bottom:1px solid var(--line);}
+  header{display:flex;align-items:center;gap:14px;padding:14px 22px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--bg);z-index:20;}
   header h1{margin:0;font-size:17px;} header h1 b{color:var(--gold);} header a{color:var(--draft);text-decoration:none;font-size:13px;}
   header .nav{margin-left:auto;display:flex;gap:14px;}
-  .wrap{padding:18px 22px 40px;max-width:1320px;margin:0 auto;}
-  .stat{color:var(--muted);font-size:13px;margin-bottom:14px;} .stat b{color:var(--fg);}
-  .ctrls{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:16px;}
-  input,select{background:var(--panel);color:var(--fg);border:1px solid var(--line);border-radius:8px;padding:8px 10px;font-size:13px;}
-  .cols{display:grid;grid-template-columns:1.4fr 1fr;gap:16px;} @media(max-width:880px){.cols{grid-template-columns:1fr;}}
-  .panel{background:var(--panel);border:1px solid var(--line);border-radius:13px;padding:16px;margin-bottom:16px;}
+  .stat{color:var(--muted);font-size:13px;padding:12px 22px 4px;} .stat b{color:var(--fg);}
+  #depthNote{font-size:12px;color:var(--muted);margin:2px 22px 8px;padding:6px 10px;border-left:2px solid var(--gold);background:rgba(200,162,75,.06);}
+  .charts{display:grid;grid-template-columns:1fr 1fr;gap:16px;padding:18px;} @media(max-width:880px){.charts{grid-template-columns:1fr;}}
+  .panel{background:var(--panel);border:1px solid var(--line);border-radius:13px;padding:16px;}
   .panel h3{margin:0 0 12px;font-size:12px;text-transform:uppercase;letter-spacing:.6px;color:var(--gold);}
-  table{width:100%;border-collapse:collapse;font-size:13px;} td,th{text-align:left;padding:6px;border-top:1px solid var(--line);}
-  th{color:var(--muted);font-size:11px;text-transform:uppercase;border-top:0;} td.n{text-align:right;font-variant-numeric:tabular-nums;}
-  th.srt{cursor:pointer;user-select:none;} th.srt:hover{color:var(--fg);}
-  th.srt.asc::after{content:' ▲';color:var(--gold);} th.srt.desc::after{content:' ▼';color:var(--gold);}
-  .price{color:var(--active);font-weight:600;} a.row{color:var(--draft);text-decoration:none;}
   canvas{max-height:240px;}
+  .price{color:var(--active);font-weight:600;}
 </style>
 </head>
 <body>
@@ -32,47 +31,61 @@
   <h1><b>Closed Sales</b> · LA condo comps</h1>
   <nav class="nav"><a href="/crcp.html">← CRCP</a><a href="/condos.html">Condos</a><a href="/graphics.html">Graphics</a><a href="/fha-loans.html">FHA Loans</a></nav>
 </header>
-<div class="wrap">
-  <div class="stat" id="stat">loading…</div>
-  <div id="depthNote" style="font-size:12px;color:var(--muted);margin:4px 0 10px;padding:6px 10px;border-left:2px solid #C8A24B;background:rgba(200,162,75,.06)"></div>
-  <div class="ctrls">
-    <input type="text" id="city" placeholder="filter city… e.g. Long Beach">
-    <input type="number" id="minP" placeholder="min $" step="50000">
-    <input type="number" id="maxP" placeholder="max $" step="50000">
-  </div>
-  <div class="cols">
-    <div class="panel"><h3>Sales (newest first)</h3>
-      <table id="tbl"><thead><tr><th class="srt" data-col="address">Address</th><th class="srt" data-col="city">City</th><th class="srt n" data-col="sold_price">Sold</th><th class="srt n" data-col="sold_date">Date</th></tr></thead><tbody></tbody></table>
-    </div>
-    <div>
-      <div class="panel"><h3>By city (avg price)</h3><canvas id="cityChart"></canvas></div>
-      <div class="panel"><h3>Sales by year</h3><canvas id="yearChart"></canvas></div>
-    </div>
-  </div>
+<div class="stat" id="stat">loading…</div>
+<div id="depthNote"></div>
+
+<div class="cg-shell">
+  <aside class="cg-rail" id="cgRail"></aside>
+  <div class="cg-main" id="cgMain"><div class="cg-empty" style="padding:50px">loading sold comps…</div></div>
 </div>
+
+<div class="charts">
+  <div class="panel"><h3>By city (avg price)</h3><canvas id="cityChart"></canvas></div>
+  <div class="panel"><h3>Sales by year</h3><canvas id="yearChart"></canvas></div>
+</div>
+
 <script>
 const $=s=>document.querySelector(s);
 const money=n=>n?'$'+Number(n).toLocaleString():'—';
-let cityChart, yearChart, lastRows=[], sort={col:'sold_date',dir:-1};
-function renderRows(){
-  const rows=lastRows.slice().sort((a,b)=>{ let x=a[sort.col],y=b[sort.col];
-    if(sort.col==='sold_price'){x=+x||0;y=+y||0; return sort.dir*(x-y);}
-    return sort.dir*String(x||'').localeCompare(String(y||''),undefined,{numeric:true}); });
-  $('#tbl tbody').innerHTML=rows.slice(0,400).map(r=>`<tr><td>${r.url?`<a class="row" href="${r.url}" target="_blank" rel="noopener noreferrer">${r.address}</a>`:r.address}</td><td>${r.city}</td><td class="n price">${money(r.sold_price)}</td><td class="n" style="color:var(--muted)">${(r.sold_date||'').toString().slice(0,10)}</td></tr>`).join('');
-  document.querySelectorAll('#tbl th.srt').forEach(th=>{ th.classList.remove('asc','desc'); if(th.dataset.col===sort.col) th.classList.add(sort.dir>0?'asc':'desc'); });
-}
-document.querySelectorAll('#tbl th.srt').forEach(th=>th.onclick=()=>{ const c=th.dataset.col; if(sort.col===c) sort.dir*=-1; else {sort.col=c; sort.dir=(c==='sold_price'||c==='sold_date')?-1:1;} renderRows(); });
+let cityChart, yearChart;
+
+// Every data point — each toggleable (cards + list columns).
+const FIELDS=[
+  {k:'sold_price',l:'Sold',      money:1, col:1, def:1},
+  {k:'sold_date', l:'Sold date',          col:1, def:1, calc:r=>String(r.sold_date||'').slice(0,10)},
+  {k:'beds',      l:'Beds',      num:1,   col:1, def:1},
+  {k:'baths',     l:'Baths',     num:1,   col:1, def:1},
+  {k:'sqft',      l:'Sq Ft',     num:1,   col:1, def:1},
+  {k:'ppsf',      l:'$/Sq Ft',   money:1, col:1, def:1, calc:r=>r.sqft?Math.round(r.sold_price/r.sqft):null},
+  {k:'city',      l:'City',               col:1, def:1},
+  {k:'zip',       l:'ZIP',                col:1, def:0},
+];
+const FACETS=[
+  {k:'city',      l:'City',       type:'chip', max:20},
+  {k:'year',      l:'Sold year',  type:'chip', calc:r=>String(r.sold_date||'').slice(0,4), max:12},
+  {k:'beds',      l:'Beds',       type:'chip', calc:r=>String(r.beds||'—')},
+  {k:'sold_price',l:'Sold price $',type:'range', money:1},
+  {k:'sqft',      l:'Sq Ft',      type:'range'},
+  {k:'ppsf',      l:'$/Sq Ft',    type:'range', money:1, calc:r=>r.sqft?Math.round(r.sold_price/r.sqft):null},
+];
+
 async function load(){
-  const p=new URLSearchParams();
-  if($('#city').value) p.set('city',$('#city').value.trim());
-  if($('#minP').value) p.set('minPrice',$('#minP').value);
-  if($('#maxP').value) p.set('maxPrice',$('#maxP').value);
-  const d=await (await fetch('/api/closed-sales?'+p)).json();
+  let d;
+  try{ d=await (await fetch('/api/closed-sales?limit=5000')).json(); }
+  catch(e){ $('#cgMain').innerHTML='<div class="cg-empty" style="padding:50px">sold-comps load error.</div>'; return; }
   const t=d.total||{};
   $('#stat').innerHTML=`<b>${(t.n||0).toLocaleString()}</b> sold condos · avg <b>${money(t.avg_price)}</b> · ${(t.oldest||'').toString().slice(0,10)} → ${(t.newest||'').toString().slice(0,10)} · <span style="color:var(--muted)">${d.source||''}</span>`;
   if(d.depthNote) $('#depthNote').textContent='ℹ '+d.depthNote;
-  lastRows=d.rows||[]; renderRows();
-  // charts
+
+  CrcpGrid.mount({
+    data:d.rows||[], fields:FIELDS, facets:FACETS,
+    mount:'#cgMain', rail:'#cgRail', view:'list', cols:3,
+    storageKey:'sales', titleCol:'Address',
+    title:r=>({ name:r.address||'—', sub:[r.city,r.zip].filter(Boolean).join(' '), href:r.url||'' }),
+    search:r=>((r.address||'')+' '+(r.city||'')+' '+(r.zip||'')),
+  });
+
+  // charts (over the loaded window)
   const bc=d.byCity||[];
   if(cityChart) cityChart.destroy();
   cityChart=new Chart($('#cityChart'),{type:'bar',data:{labels:bc.map(c=>c.city),datasets:[{label:'avg $',data:bc.map(c=>c.avg_price),backgroundColor:'#C8A24B'}]},options:{plugins:{legend:{display:false}},scales:{x:{ticks:{color:'#8A93A2',font:{size:9}}},y:{ticks:{color:'#8A93A2'}}}}});
@@ -80,7 +93,6 @@ async function load(){
   if(yearChart) yearChart.destroy();
   yearChart=new Chart($('#yearChart'),{type:'line',data:{labels:by.map(y=>y.yr),datasets:[{label:'sales',data:by.map(y=>y.n),borderColor:'#70AD47',backgroundColor:'rgba(112,173,71,.15)',fill:true,tension:.3}]},options:{plugins:{legend:{display:false}},scales:{x:{ticks:{color:'#8A93A2'}},y:{ticks:{color:'#8A93A2'}}}}});
 }
-['city','minP','maxP'].forEach(id=>{ let t; $('#'+id).oninput=()=>{ clearTimeout(t); t=setTimeout(load,400); }; });
 load();
 </script>
 </body>
diff --git a/scripts/serve.js b/scripts/serve.js
index 10729d6..b53b68b 100644
--- a/scripts/serve.js
+++ b/scripts/serve.js
@@ -546,7 +546,8 @@ app.get('/api/closed-sales', async (req, res) => {
     if (minP) { args.push(minP); where.push(`sold_price>=$${args.length}`); }
     if (maxP) { args.push(maxP); where.push(`sold_price<=$${args.length}`); }
     const w = 'WHERE ' + where.join(' AND ');
-    const rows = await q(`SELECT address, city, zip, sold_price, sold_date, beds, baths, sqft, url FROM closed_sale ${w} ORDER BY sold_date DESC NULLS LAST LIMIT 600`, args);
+    const lim = Math.min(Math.max(+req.query.limit || 600, 1), 8000); // clamped Number — safe to interpolate
+    const rows = await q(`SELECT address, city, zip, sold_price, sold_date, beds, baths, sqft, url FROM closed_sale ${w} ORDER BY sold_date DESC NULLS LAST LIMIT ${lim}`, args);
     const byCity = await q(`SELECT city, count(*)::int n, round(avg(sold_price))::bigint avg_price FROM closed_sale ${w} GROUP BY city ORDER BY n DESC LIMIT 16`, args);
     const byYear = await q(`SELECT extract(year FROM sold_date)::int yr, count(*)::int n, round(avg(sold_price))::bigint avg_price FROM closed_sale ${w} AND sold_date IS NOT NULL GROUP BY 1 ORDER BY 1`, args);
     const tot = (await q(`SELECT count(*)::int n, round(avg(sold_price))::bigint avg_price, min(sold_date) oldest, max(sold_date) newest FROM closed_sale ${w}`, args))[0];

← 1117168 fha-loans: Amazon left-rail + field toggles + grid/list via  ·  back to Commercialrealestate  ·  fix: guard brokers.map in graphics.html against non-array pa d7077f3 →