← 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
M data/markets.jsonM data/news.jsonM data/ticker.jsonM public/backend.htmlA public/closings.htmlM server.js
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 & 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 & 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=>({'&':'&','<':'<','>':'>','"':'"'}[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 →