[object Object]

← back to Rentv 2026

rentv backend: Property Closings view (Sale deals w/ address·date·price, sortable+searchable)

8a06b7a928bde3a4ddc7f5395a593213a4984f78 · 2026-08-04 15:01:50 -0700 · Steve Abrams

Files touched

Diff

commit 8a06b7a928bde3a4ddc7f5395a593213a4984f78
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Aug 4 15:01:50 2026 -0700

    rentv backend: Property Closings view (Sale deals w/ address·date·price, sortable+searchable)
---
 data/markets.json    |   2 +-
 data/news.json       |   2 +-
 data/ticker.json     |   2 +-
 public/backend.html  |   1 +
 public/closings.html | 155 +++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js            |   3 +
 6 files changed, 162 insertions(+), 3 deletions(-)

diff --git a/data/markets.json b/data/markets.json
index 52748920..a54e3358 100644
--- a/data/markets.json
+++ b/data/markets.json
@@ -1,5 +1,5 @@
 {
-  "fetched_at": "2026-08-04T21:33:14.395Z",
+  "fetched_at": "2026-08-04T21:53:15.145Z",
   "source": "Yahoo Finance — live (CBOE Treasury yields, Dow Jones/MSCI REIT indices)",
   "curve": [
     {
diff --git a/data/news.json b/data/news.json
index 1460c940..cf8a66d8 100644
--- a/data/news.json
+++ b/data/news.json
@@ -1,6 +1,6 @@
 {
  "source": "rentv.com — live",
- "fetched_at": "2026-08-04T21:33:14.135Z",
+ "fetched_at": "2026-08-04T21:53:14.917Z",
  "count": 30,
  "items": [
   {
diff --git a/data/ticker.json b/data/ticker.json
index 769d88c6..e038c3b1 100644
--- a/data/ticker.json
+++ b/data/ticker.json
@@ -1,5 +1,5 @@
 {
-  "fetched_at": "2026-08-04T21:33:14.364Z",
+  "fetched_at": "2026-08-04T21:53:15.116Z",
   "la_office_vac": {
     "label": "LA OFFICE VAC",
     "value": "23.1%",
diff --git a/public/backend.html b/public/backend.html
index 1095e6e8..e9e9e894 100644
--- a/public/backend.html
+++ b/public/backend.html
@@ -39,6 +39,7 @@
     <a class="card" href="/audience"><div class="ico">🎯</div><b>Audience &amp; CRM</b><span>Subscribers + sublease brokers, auto-segmented</span></a>
     <a class="card" href="/desk-admin"><div class="ico">✍️</div><b>Desk Admin</b><span>Add / withdraw sublease inventory</span></a>
     <a class="card" href="/admin/pr-intelligence"><div class="ico">📡</div><b>CRE PR Intelligence</b><span>CA/AZ press-contact research · letters · replies</span></a>
+    <a class="card" href="/closings"><div class="ico">🏠</div><b>Property Closings</b><span>Every reported closed sale · address · date · price</span></a>
   </div>
 
   <div class="sect">Build &amp; Dashboards</div>
diff --git a/public/closings.html b/public/closings.html
new file mode 100644
index 00000000..58bb4d60
--- /dev/null
+++ b/public/closings.html
@@ -0,0 +1,155 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Property Closings — RENTV Backend</title>
+<style>
+  :root{--ink:#14171a;--sub:#5b6570;--line:#e4e7ea;--wash:#f4f6f8;--red:#c0392b;--green:#1a7f4b;
+        --serif:Georgia,'Times New Roman',serif}
+  *{box-sizing:border-box;margin:0;padding:0}
+  body{background:var(--wash);color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
+  header{background:#fff;border-bottom:1px solid var(--line);padding:14px 22px;display:flex;align-items:center;gap:14px;flex-wrap:wrap;position:sticky;top:0;z-index:20}
+  header h1{font-family:var(--serif);font-size:20px;flex:1;min-width:180px}
+  header .tag{background:var(--ink);color:#fff;font-size:11px;font-weight:700;padding:3px 8px;border-radius:4px;text-transform:uppercase;letter-spacing:.05em}
+  header a.back{color:var(--sub);text-decoration:none;font-weight:600;font-size:13px}
+  header a.back:hover{color:var(--red)}
+  .stats{display:flex;gap:12px;padding:16px 22px 0;flex-wrap:wrap}
+  .stat{background:#fff;border:1px solid var(--line);border-radius:8px;padding:10px 16px;min-width:130px}
+  .stat .l{font-size:11px;color:var(--sub);text-transform:uppercase;letter-spacing:.04em}
+  .stat .v{font-family:var(--serif);font-size:22px;font-weight:700;margin-top:2px}
+  .controls{display:flex;gap:10px;align-items:center;padding:16px 22px;flex-wrap:wrap}
+  .controls input[type=text]{flex:1;min-width:220px;padding:9px 12px;border:1px solid var(--line);border-radius:6px;font-size:14px}
+  .controls .hint{font-size:12px;color:var(--sub)}
+  .controls button{background:#fff;border:1px solid var(--line);border-radius:6px;padding:8px 12px;font-size:12px;font-weight:600;cursor:pointer;color:var(--sub)}
+  .controls button:hover{border-color:var(--red);color:var(--red)}
+  main{padding:0 22px 40px}
+  table{width:100%;border-collapse:collapse;background:#fff;border:1px solid var(--line);border-radius:10px;overflow:hidden}
+  th,td{padding:10px 12px;text-align:left;border-bottom:1px solid var(--line);font-size:13px;vertical-align:top}
+  th{background:var(--wash);font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--sub);cursor:pointer;user-select:none;white-space:nowrap;position:sticky;top:57px}
+  th:hover{color:var(--red)}
+  th .arr{opacity:.35;font-size:10px}
+  th.sorted .arr{opacity:1;color:var(--red)}
+  td.addr{font-weight:600}
+  td.addr a{color:var(--ink);text-decoration:none;border-bottom:1px dotted var(--sub)}
+  td.addr a:hover{color:var(--red)}
+  td.price{font-family:var(--serif);font-weight:700;white-space:nowrap}
+  .badge{display:inline-block;background:var(--wash);border:1px solid var(--line);border-radius:4px;padding:1px 7px;font-size:11px;font-weight:600;color:var(--sub)}
+  tr.deal{cursor:pointer}
+  tr.deal:hover{background:#fbfcfd}
+  tr.detail td{background:#fbfcfd;color:var(--sub);font-size:13px;line-height:1.6;padding-top:0}
+  tr.detail .meta{margin-top:6px;font-size:12px}
+  tr.detail .meta b{color:var(--ink)}
+  .empty{text-align:center;color:var(--sub);padding:50px}
+  .loading{text-align:center;color:var(--sub);padding:60px;font-size:16px}
+</style>
+</head>
+<body>
+<header>
+  <h1>Property Closings</h1><span class="tag">Backend</span>
+  <a class="back" href="/backend">← Backend</a>
+</header>
+
+<div class="stats" id="stats"></div>
+<div class="controls">
+  <input type="text" id="q" placeholder="Search all fields — address, city, buyer, type… (space = AND)">
+  <span class="hint">click a row for the full story · click a column to sort</span>
+  <button id="reset">Reset</button>
+</div>
+<main>
+  <div id="wrap"><div class="loading">Loading closings…</div></div>
+</main>
+
+<script>
+const MONTHS={Jan:0,Feb:1,Mar:2,Apr:3,May:4,Jun:5,Jul:6,Aug:7,Sep:8,Oct:9,Nov:10,Dec:11};
+const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
+// Date is encoded in the image filename, e.g. ...Jul182026.jpg -> 2026-07-18.
+function parseDate(img){
+  const m=String(img||'').match(/([A-Z][a-z]{2})(\d{1,2})(\d{4})\.(?:jpg|jpeg|png|webp)/i);
+  if(!m) return null;
+  const mo=MONTHS[m[1][0].toUpperCase()+m[1].slice(1).toLowerCase()];
+  if(mo==null) return null;
+  return new Date(Date.UTC(+m[3], mo, +m[2]));
+}
+const fmtDate=d=>d?d.toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}):'—';
+const fmtISO=d=>d?d.toISOString().slice(0,10):'';
+
+let ROWS=[];
+let sort={key:'date',dir:-1};
+const COLS=[
+  {key:'date',  label:'Date',        get:r=>r.date?r.date.getTime():0,           num:true},
+  {key:'address',label:'Address',    get:r=>r.address||'zzz'},
+  {key:'place', label:'City / State',get:r=>(r.city||'')+r.state},
+  {key:'ptype', label:'Type',        get:r=>r.property_type||''},
+  {key:'amount',label:'Price',       get:r=>r.amount||0,                         num:true},
+  {key:'size',  label:'Size',        get:r=>r.units||r.sqft||0,                  num:true},
+  {key:'title', label:'Deal',        get:r=>r.title||''},
+];
+
+function loadState(){ try{const s=JSON.parse(localStorage.getItem('rentv_closings')||'{}');if(s.sort)sort=s.sort;if(s.q)document.getElementById('q').value=s.q;}catch{} }
+function saveState(){ localStorage.setItem('rentv_closings',JSON.stringify({sort,q:document.getElementById('q').value})); }
+
+function filtered(){
+  const terms=document.getElementById('q').value.toLowerCase().split(' ').filter(Boolean);
+  let rows=ROWS.filter(r=>{
+    if(!terms.length) return true;
+    const hay=[r.address,r.city,r.state,r.property_type,r.title,r.summary,r.amount_label].join(' ').toLowerCase();
+    return terms.every(t=>hay.includes(t));
+  });
+  const col=COLS.find(c=>c.key===sort.key)||COLS[0];
+  rows.sort((a,b)=>{const x=col.get(a),y=col.get(b);const c=col.num?(x-y):String(x).localeCompare(String(y));return c*sort.dir;});
+  return rows;
+}
+
+function render(){
+  const rows=filtered();
+  const total=ROWS.reduce((s,r)=>s+(r.amount||0),0);
+  const shownVol=rows.reduce((s,r)=>s+(r.amount||0),0);
+  document.getElementById('stats').innerHTML=`
+    <div class="stat"><div class="l">Closings</div><div class="v">${rows.length}<span style="font-size:13px;color:var(--sub)"> / ${ROWS.length}</span></div></div>
+    <div class="stat"><div class="l">Volume (shown)</div><div class="v">$${(shownVol/1e6).toFixed(1)}M</div></div>
+    <div class="stat"><div class="l">Total volume</div><div class="v">$${(total/1e6).toFixed(1)}M</div></div>`;
+  if(!rows.length){document.getElementById('wrap').innerHTML='<div class="empty">No closings match your search.</div>';return;}
+  const head='<tr>'+COLS.map(c=>`<th data-k="${c.key}" class="${sort.key===c.key?'sorted':''}">${c.label} <span class="arr">${sort.key===c.key?(sort.dir<0?'▼':'▲'):'↕'}</span></th>`).join('')+'</tr>';
+  const body=rows.map((r,i)=>{
+    const link=r.id?('/news/'+encodeURIComponent(r.id)):(r.url||'#');
+    const addr=r.address?`<a href="${link}">${esc(r.address)}</a>`:'<span style="color:var(--sub)">—</span>';
+    const size=r.units?`${r.units} units`:(r.sqft?`${r.sqft.toLocaleString()} SF`:'—');
+    return `<tr class="deal" data-i="${i}">
+      <td style="white-space:nowrap" title="${fmtISO(r.date)}">${fmtDate(r.date)}</td>
+      <td class="addr">${addr}</td>
+      <td>${esc(r.city)}${r.state?', '+esc(r.state):''}</td>
+      <td><span class="badge">${esc(r.property_type||'—')}</span></td>
+      <td class="price">${esc(r.amount_label||(r.amount?'$'+r.amount.toLocaleString():'—'))}</td>
+      <td style="white-space:nowrap">${size}</td>
+      <td>${esc(r.title||'')}</td>
+    </tr>
+    <tr class="detail" id="det-${i}" style="display:none"><td colspan="${COLS.length}">
+      ${esc(r.summary||'No summary.')}
+      <div class="meta"><b>Txn:</b> ${esc(r.txn_type)} · <b>Occupancy:</b> ${r.occupancy_pct!=null?r.occupancy_pct+'%':'—'} · <b>Year built:</b> ${r.year_built||'—'} · <a href="${link}" style="color:var(--red);font-weight:700">Full story →</a></div>
+    </td></tr>`;
+  }).join('');
+  document.getElementById('wrap').innerHTML=`<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
+  document.querySelectorAll('th[data-k]').forEach(th=>th.onclick=()=>{
+    const k=th.dataset.k; sort.dir=(sort.key===k)?-sort.dir:(COLS.find(c=>c.key===k).num?-1:1); sort.key=k; saveState(); render();
+  });
+  document.querySelectorAll('tr.deal').forEach(tr=>tr.onclick=e=>{
+    if(e.target.closest('a'))return; const d=document.getElementById('det-'+tr.dataset.i); d.style.display=d.style.display==='none'?'':'none';
+  });
+}
+
+document.getElementById('q').oninput=()=>{saveState();render();};
+document.getElementById('reset').onclick=()=>{document.getElementById('q').value='';sort={key:'date',dir:-1};saveState();render();};
+
+(async()=>{
+  loadState();
+  try{
+    const res=await fetch('/api/deals?txn=Sale&limit=200');
+    const data=await res.json();
+    ROWS=(data.deals||[]).map(d=>({...d,date:parseDate(d.image)}));
+    render();
+  }catch(e){ document.getElementById('wrap').innerHTML='<div class="empty">Failed to load: '+esc(e.message)+'</div>'; }
+})();
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
index caafa010..29474d77 100644
--- a/server.js
+++ b/server.js
@@ -328,6 +328,9 @@ app.get('/news/:id', (_q, r) => sendPage(r, path.join(PUB, 'article.html')));
 app.get('/review', (_q, r) => sendPage(r, path.join(PUB, 'review.html')));
 // Backend hub — admin-only landing linking every internal tool (the "Backend" toggle target)
 app.get('/backend', adminOnly, (_q, r) => sendPage(r, path.join(PUB, 'backend.html')));
+// Property Closings — admin view of every reported closed sale (address · date · price),
+// sourced from the live deals feed filtered to txn_type=Sale (see /api/deals?txn=Sale).
+app.get('/closings', adminOnly, (_q, r) => sendPage(r, path.join(PUB, 'closings.html')));
 // 10 site VERSIONS — internal design/layout tool → admin-only
 app.get('/versions', adminOnly, (_q, r) => sendPage(r, path.join(PUB, 'versions', 'index.html')));
 // 10 ADMIN versions — dashboards over the real internal data → admin-only

← 3e8842dc auto-save: 2026-08-04T14:34:11 (3 files) — data/markets.json  ·  back to Rentv 2026  ·  rentv closings: parse full-month-name dates (June/July) → 18 45ac1811 →