[object Object]

← back to Commercialrealestate

auto-save: 2026-06-28T10:14:06 (2 files) — scripts/serve.js public/sales.html

56c1898738d9680a644d9357eefdd9632a89f9e8 · 2026-06-28 10:14:12 -0700 · Steve Abrams

Files touched

Diff

commit 56c1898738d9680a644d9357eefdd9632a89f9e8
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Jun 28 10:14:12 2026 -0700

    auto-save: 2026-06-28T10:14:06 (2 files) — scripts/serve.js public/sales.html
---
 public/sales.html | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 scripts/serve.js  | 20 +++++++++++++++
 2 files changed, 94 insertions(+)

diff --git a/public/sales.html b/public/sales.html
new file mode 100644
index 0000000..3a25a4f
--- /dev/null
+++ b/public/sales.html
@@ -0,0 +1,74 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Closed Sales — LA Condo Comps</title>
+<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
+<style>
+  :root{ --bg:#0c0f15; --panel:#141925; --fg:#E6E8EC; --muted:#8A93A2; --gold:#C8A24B; --active:#70AD47; --draft:#5B9BD5; --line:#232a38; }
+  *{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 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;}
+  .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;}
+  .price{color:var(--active);font-weight:600;} a.row{color:var(--draft);text-decoration:none;}
+  canvas{max-height:240px;}
+</style>
+</head>
+<body>
+<header>
+  <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></nav>
+</header>
+<div class="wrap">
+  <div class="stat" id="stat">loading…</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>Address</th><th>City</th><th class="n">Sold</th><th class="n">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>
+<script>
+const $=s=>document.querySelector(s);
+const money=n=>n?'$'+Number(n).toLocaleString():'—';
+let cityChart, yearChart;
+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();
+  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>`;
+  $('#tbl tbody').innerHTML=(d.rows||[]).slice(0,300).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('');
+  // charts
+  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'}}}}});
+  const by=d.byYear||[];
+  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>
+</html>
diff --git a/scripts/serve.js b/scripts/serve.js
index 9d625d9..a07080a 100644
--- a/scripts/serve.js
+++ b/scripts/serve.js
@@ -352,6 +352,26 @@ app.post('/api/broker/pitch', async (req, res) => {
   } catch (e) { res.status(502).json({ error: String(e.message).split('\n')[0] }); }
 });
 
+// Closed-sales comps (Redfin sold, ~5yr) — browsable market dataset + aggregates. Read-only.
+app.get('/api/closed-sales', async (req, res) => {
+  if (!brokerdb) return res.json({ rows: [], stats: {}, unavailable: true });
+  try {
+    const q = (s, a) => brokerdb.pool.query(s, a).then(r => r.rows);
+    const city = String(req.query.city || '').trim();
+    const minP = +req.query.minPrice || 0, maxP = +req.query.maxPrice || 0;
+    const where = ['sold_price>0']; const args = [];
+    if (city) { args.push(city); where.push(`city ILIKE $${args.length}`); }
+    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 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];
+    res.json({ rows, byCity, byYear, total: tot, source: 'Redfin sold feed (gis-csv, ~5yr) — area market comps' });
+  } catch (e) { res.status(502).json({ error: String(e.message).split('\n')[0], rows: [] }); }
+});
+
 app.use('/data', express.static(path.join(ROOT, 'data')));
 app.use('/', express.static(path.join(ROOT, 'public')));
 

← ed38493 CRE residential agents: final capture artifact — 637 agents  ·  back to Commercialrealestate  ·  CRCP: sortable columns on broker + firm tables (click any he 5287876 →