← 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
M public/crcp-grid.jsM public/sales.htmlM scripts/serve.js
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 →