[object Object]

← back to Rentv

RENTV: Deals page → backend-only + editable override layer (mirror closings, all txn types), v0.20.0 — TK-10255

d7aca3d55a5711494269eb42e0c53f02a6d881c9 · 2026-08-05 14:40:30 -0700 · Steve Abrams

- /deals gated adminOnly + INTERNAL_PAGE + nav-hidden from user-tier; public /api/deals UNCHANGED (metros/watchlist/etc still use it)
- new /api/deals-manage: deals.json ∪ full corpus (all txns, listings filtered) + deals-overrides.json layer; add/edit/hide/restore, txn_type editable
- deals.html rebuilt as backend editor (mirror closings): modal w/ txn-type select, per-card Edit/Hide/Restore, manual/edited/hidden badges, parties line, buyer/seller/broker search
- timers tuned: sync 30min, crawl 6h

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit d7aca3d55a5711494269eb42e0c53f02a6d881c9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Aug 5 14:40:30 2026 -0700

    RENTV: Deals page → backend-only + editable override layer (mirror closings, all txn types), v0.20.0 — TK-10255
    
    - /deals gated adminOnly + INTERNAL_PAGE + nav-hidden from user-tier; public /api/deals UNCHANGED (metros/watchlist/etc still use it)
    - new /api/deals-manage: deals.json ∪ full corpus (all txns, listings filtered) + deals-overrides.json layer; add/edit/hide/restore, txn_type editable
    - deals.html rebuilt as backend editor (mirror closings): modal w/ txn-type select, per-card Edit/Hide/Restore, manual/edited/hidden badges, parties line, buyer/seller/broker search
    - timers tuned: sync 30min, crawl 6h
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 data/deals-overrides.json |   6 +
 package-lock.json         |   4 +-
 package.json              |   2 +-
 public/deals.html         | 339 ++++++++++++++++++++++++++++++++++++----------
 server.js                 | 110 ++++++++++++++-
 5 files changed, 381 insertions(+), 80 deletions(-)

diff --git a/data/deals-overrides.json b/data/deals-overrides.json
new file mode 100644
index 00000000..866491eb
--- /dev/null
+++ b/data/deals-overrides.json
@@ -0,0 +1,6 @@
+{
+ "adds": [],
+ "edits": {},
+ "hides": [],
+ "updated_at": "2026-08-05T21:40:01.203Z"
+}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index b3d0028c..084a89ac 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "rentv",
-  "version": "0.19.1",
+  "version": "0.20.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "rentv",
-      "version": "0.19.1",
+      "version": "0.20.0",
       "dependencies": {
         "express": "^4.21.2",
         "pg": "^8.22.0"
diff --git a/package.json b/package.json
index 23966541..e99df0b5 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "rentv",
-  "version": "0.19.1",
+  "version": "0.20.0",
   "private": true,
   "main": "server.js",
   "scripts": {
diff --git a/public/deals.html b/public/deals.html
index 4026663d..2b4d4928 100644
--- a/public/deals.html
+++ b/public/deals.html
@@ -2,8 +2,9 @@
 <html lang="en">
 <head>
 <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
-<title>CRE Deal Listings — RENTV.com</title>
-<meta name="description" content="Live commercial real estate deal listings — sales, leases, financings and developments across the Western U.S., parsed from RENTV.com transaction reporting.">
+<title>Deals — Backend Editor · RENTV.com</title>
+<meta name="robots" content="noindex,nofollow">
+<meta name="description" content="Backend editor for RENTV CRE deals — the live deal feed plus a manual add / edit / hide override layer. Admin-only.">
 <link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
 <link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
 <style>
@@ -17,11 +18,12 @@
   .util .live{display:inline-flex;align-items:center;gap:7px;letter-spacing:.05em;text-transform:uppercase;font-size:11px}
   .util .dot{width:7px;height:7px;border-radius:50%;background:#22c55e;animation:pulse 2s infinite}
   @keyframes pulse{0%{box-shadow:0 0 0 0 rgba(34,197,94,.6)}70%{box-shadow:0 0 0 7px rgba(34,197,94,0)}100%{box-shadow:0 0 0 0 rgba(34,197,94,0)}}
-  .util .acts a{margin-left:14px;color:#cfd4d9}.util .acts a.sub{color:#fff;background:var(--red);padding:4px 12px;border-radius:3px;font-weight:700}
+  .util .acts a{margin-left:14px;color:#cfd4d9}
   .mast{background:#fff;border-bottom:2px solid var(--ink);padding:16px 0 12px}
   .mast .wrap{display:flex;align-items:center;justify-content:space-between}
   .logo{font-family:var(--serif);font-weight:700;font-size:32px;letter-spacing:-1px}.logo span{color:var(--red)}
   .logo small{display:block;font-size:11px;font-weight:600;letter-spacing:3px;color:var(--sub);text-transform:uppercase;margin-top:2px;font-family:var(--sans)}
+  .backtag{font-size:11px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:#fff;background:var(--ink);padding:5px 11px;border-radius:999px}
   nav{background:#fff;border-bottom:1px solid var(--line)}
   nav .wrap{display:flex;gap:2px;align-items:center;height:46px;overflow-x:auto}
   nav a{font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:13px 8px;border-bottom:3px solid transparent;white-space:nowrap}
@@ -33,52 +35,86 @@
   .controls{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:18px 0 6px}
   .pill{font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:7px 13px;border:1px solid var(--line);border-radius:999px;background:#fff;cursor:pointer;color:var(--sub)}
   .pill.on{background:var(--ink);color:#fff;border-color:var(--ink)}
-  .controls input[type=search]{margin-left:auto;font-family:var(--sans);font-size:13px;padding:8px 12px;border:1px solid var(--line);border-radius:6px;min-width:200px}
   .ctl{display:inline-flex;align-items:center;gap:7px;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--sub)}
   .ctl select{font-family:var(--sans);font-size:13px;font-weight:600;text-transform:none;letter-spacing:0;color:var(--ink);padding:7px 10px;border:1px solid var(--line);border-radius:6px;background:#fff;cursor:pointer}
   .ctl.dens input[type=range]{width:110px;accent-color:var(--red);cursor:pointer}
+  .ctl.chk{cursor:pointer}.ctl.chk input{accent-color:var(--red);cursor:pointer}
+  .controls input[type=search]{margin-left:auto;font-family:var(--sans);font-size:13px;padding:8px 12px;border:1px solid var(--line);border-radius:6px;min-width:200px}
+  .btn{font-family:var(--sans);font-size:13px;font-weight:700;padding:8px 15px;border-radius:6px;border:1px solid var(--ink);background:var(--ink);color:#fff;cursor:pointer}
+  .btn.add{background:var(--red);border-color:var(--red)}
+  .btn.ghost{background:#fff;color:var(--ink)}
   .count{font-size:12px;color:var(--sub);margin:6px 0 16px}
   .grid{display:grid;grid-template-columns:repeat(var(--cols,3),1fr);gap:16px}
   .card{background:#fff;border:1px solid var(--line);border-radius:10px;overflow:hidden;display:flex;flex-direction:column;transition:box-shadow .15s,transform .15s}
   .card:hover{box-shadow:0 8px 24px rgba(0,0,0,.09);transform:translateY(-2px)}
+  .card.hid{opacity:.5;filter:grayscale(.5)}
+  .card.man{border-color:var(--red)}
   .card .ph{aspect-ratio:16/9;background:var(--wash) center/cover no-repeat;position:relative}
-  .card .badges{position:absolute;top:10px;left:10px;display:flex;gap:6px}
+  .card .badges{position:absolute;top:10px;left:10px;display:flex;gap:6px;flex-wrap:wrap;max-width:80%}
   .badge{font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;padding:4px 8px;border-radius:4px;color:#fff;background:var(--ink)}
   .badge.txn{background:var(--red)}
+  .badge.man{background:#7c3aed}.badge.hid{background:#6b7280}.badge.edt{background:#0369a1}
+  .card .date{position:absolute;top:10px;right:10px;background:rgba(255,255,255,.94);color:var(--ink);font-size:11px;font-weight:800;letter-spacing:.03em;padding:4px 9px;border-radius:4px}
   .card .amt{position:absolute;bottom:10px;right:10px;background:rgba(20,23,26,.92);color:#fff;font-family:var(--serif);font-weight:700;font-size:18px;padding:4px 10px;border-radius:5px}
-  .card .body{padding:14px 16px 16px;display:flex;flex-direction:column;gap:8px;flex:1}
-  .card h3{font-family:var(--serif);font-size:17px;line-height:1.25}
+  .card .body{padding:14px 16px 14px;display:flex;flex-direction:column;gap:7px;flex:1}
+  .card .addr{font-family:var(--serif);font-size:18px;line-height:1.2;font-weight:700}
+  .card h3{font-size:13px;color:var(--sub);font-weight:600;line-height:1.35}
   .card .meta{font-size:12px;color:var(--sub);font-weight:600;display:flex;flex-wrap:wrap;gap:4px 10px}
   .card .meta b{color:var(--ink)}
-  .card .sum{font-size:13px;color:var(--sub);line-height:1.5}
-  .card .more{margin-top:auto;font-size:12px;font-weight:700;color:var(--red);text-transform:uppercase;letter-spacing:.04em}
-  .empty{background:#fff;border:1px dashed var(--line);border-radius:10px;padding:40px;text-align:center;color:var(--sub)}
+  .card .meta.parties{font-size:11px;gap:3px 8px}
+  .card .meta.parties span{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block}
+  .card .cardfoot{margin-top:auto;display:flex;align-items:center;justify-content:space-between;gap:8px;padding-top:10px;border-top:1px solid var(--line)}
+  .card .more{font-size:12px;font-weight:700;color:var(--red);text-transform:uppercase;letter-spacing:.04em}
+  .card .acts{display:flex;gap:6px}
+  .card .acts button{font-family:var(--sans);font-size:11px;font-weight:700;padding:5px 10px;border-radius:5px;border:1px solid var(--line);background:#fff;color:var(--ink);cursor:pointer}
+  .card .acts button:hover{border-color:var(--ink)}
+  .card .acts button.del{color:var(--red);border-color:#f0c9ce}
+  .card .acts button.res{color:#0369a1;border-color:#bfe0f0}
+  .empty{background:#fff;border:1px dashed var(--line);border-radius:10px;padding:40px;text-align:center;color:var(--sub);grid-column:1/-1}
   footer{background:var(--ink);color:#8b939b;margin-top:40px;padding:26px 0;font-size:12px}
   footer .wrap{display:flex;justify-content:space-between;flex-wrap:wrap;gap:10px}
+  /* modal editor */
+  .ov{position:fixed;inset:0;background:rgba(20,23,26,.55);display:none;align-items:flex-start;justify-content:center;padding:40px 16px;overflow:auto;z-index:50}
+  .ov.on{display:flex}
+  .modal{background:#fff;border-radius:14px;width:100%;max-width:640px;box-shadow:0 24px 60px rgba(0,0,0,.3)}
+  .modal .mh{display:flex;align-items:center;justify-content:space-between;padding:18px 22px;border-bottom:1px solid var(--line)}
+  .modal .mh h2{font-family:var(--serif);font-size:22px}
+  .modal .mh button{font-size:22px;background:none;border:none;cursor:pointer;color:var(--sub);line-height:1}
+  .modal form{padding:18px 22px 4px}
+  .modal .row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
+  .modal label{display:block;margin-bottom:12px;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--sub)}
+  .modal input,.modal select,.modal textarea{width:100%;font-family:var(--sans);font-size:14px;font-weight:500;text-transform:none;letter-spacing:0;color:var(--ink);margin-top:5px;padding:9px 11px;border:1px solid var(--line);border-radius:7px;background:#fff}
+  .modal textarea{min-height:70px;resize:vertical}
+  .modal .mf{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:14px 22px 20px;border-top:1px solid var(--line);margin-top:8px}
+  .modal .mf .msg{font-size:12px;color:var(--sub)}
   @media(max-width:900px){.grid{grid-template-columns:1fr 1fr}}
-  @media(max-width:600px){.grid{grid-template-columns:1fr!important}.controls input[type=search]{margin-left:0;width:100%}}
+  @media(max-width:600px){.grid{grid-template-columns:1fr!important}.controls input[type=search]{margin-left:0;width:100%}.modal .row{grid-template-columns:1fr}}
 </style>
 </head>
 <body>
   <div class="util"><div class="wrap">
-    <span class="live"><span class="dot"></span> Live CRE deal flow</span>
-    <div class="acts"><a href="/subscribe">Newsletters</a><a href="/advertise">Advertise</a><a class="sub" href="/subscribe">Subscribe</a></div>
+    <span class="live"><span class="dot"></span> Backend · Deals editor</span>
+    <div class="acts"><a href="/backend">← Backend hub</a><a href="/deals-log.html">Deals Log</a></div>
   </div></div>
   <header class="mast"><div class="wrap">
     <a class="logo" href="/">REN<span>TV</span>.com<small>Commercial Real Estate News</small></a>
+    <span class="backtag">🔒 Backend only</span>
   </div></header>
   <nav><div class="wrap">
-    <a href="/">Home</a><a href="/deals" class="on">Deals</a><a href="/closings">Closings</a><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a><a href="/markets">Markets</a><a href="/la-commercial">LA Commercial</a><a href="/desk">Broker &amp; Owner Desk</a><a href="/conferences">Conferences</a><a href="/blog">Blog</a>
+    <a href="/backend">Backend</a><a href="/deals" class="on">Deals</a><a href="/closings">Closings</a><a href="/deals-log.html">Deals Log</a><a href="/desk">Broker &amp; Owner Desk</a><a href="/admin">Admin</a><a href="/blog">Blog</a>
   </div></nav>
   <main class="wrap">
     <div class="head">
-      <h1>CRE Deal Listings</h1>
-      <p>Every commercial real estate transaction RENTV.com is reporting right now — sales, leases, financings and developments across the Western U.S. — parsed into structured listings with price, property type, size and location. Each card links to the full story.</p>
+      <h1>CRE Deals</h1>
+      <p>The live deal feed from RENTV.com reporting across all transaction types, plus your manual layer. <b>Add</b> a deal the scraper missed, <b>Edit</b> any card (your changes stick — the 20-min auto-pull never overwrites them), or <b>Hide</b> ones that are wrong. Backend-only; not shown to the public site.</p>
       <div class="fresh" id="fresh">Loading…</div>
     </div>
+    <!-- txn-type filter pills -->
     <div class="controls" id="txns"></div>
+    <!-- property-type filter pills -->
     <div class="controls" id="types"></div>
     <div class="controls">
+      <button class="btn add" id="addBtn">+ Add deal</button>
       <label class="ctl"><span>Sort</span>
         <select id="sort">
           <option value="newest">Newest</option>
@@ -92,87 +128,242 @@
       <label class="ctl dens"><span>Density</span>
         <input id="dens" type="range" min="2" max="5" step="1" value="3" aria-label="Grid density (columns)">
       </label>
-      <input id="q" type="search" placeholder="Search city, property, party…" autocomplete="off">
+      <label class="ctl chk"><input type="checkbox" id="showHidden"> Show hidden</label>
+      <input id="q" type="search" placeholder="Search address, city, property, party…" autocomplete="off">
     </div>
     <div class="count" id="count"></div>
     <div class="grid" id="grid"></div>
   </main>
-  <footer><div class="wrap"><span>© RENTV.com — Commercial Real Estate News</span><span>Deal listings parsed from live RENTV.com reporting, refreshed every 20 min</span></div></footer>
+  <footer><div class="wrap"><span>© RENTV.com — Commercial Real Estate News · Backend</span><span>Base feed refreshed every 20 min · manual adds/edits/hides persist in deals-overrides.json</span></div></footer>
+
+  <!-- editor modal -->
+  <div class="ov" id="ov">
+    <div class="modal">
+      <div class="mh"><h2 id="mTitle">Add deal</h2><button id="mClose" aria-label="Close">×</button></div>
+      <form id="mForm" autocomplete="off">
+        <input type="hidden" id="f_id">
+        <label>Transaction type
+          <select id="f_txn_type">
+            <option value="">—</option>
+            <option value="Sale">Sale</option>
+            <option value="Lease">Lease</option>
+            <option value="Financing">Financing</option>
+            <option value="Development">Development</option>
+            <option value="Acquisition">Acquisition</option>
+          </select>
+        </label>
+        <label>Address / property name<input id="f_address" placeholder="1200 Wilshire Blvd"></label>
+        <div class="row">
+          <label>City<input id="f_city" placeholder="Los Angeles"></label>
+          <label>State<input id="f_state" placeholder="CA"></label>
+        </div>
+        <div class="row">
+          <label>Property type
+            <select id="f_property_type">
+              <option value="">—</option>
+              <option>Multifamily</option><option>Office</option><option>Industrial</option>
+              <option>Retail</option><option>Hospitality</option><option>Mixed-Use</option><option>Land</option>
+            </select>
+          </label>
+          <label>Amount (label)<input id="f_amount_label" placeholder="$12.5M"></label>
+        </div>
+        <div class="row">
+          <label>Close date<input id="f_close_date" type="date"></label>
+          <label>Size (label)<input id="f_size_label" placeholder="120,000 SF"></label>
+        </div>
+        <div class="row">
+          <label>Year built<input id="f_year_built" placeholder="1998"></label>
+          <label>Headline / title<input id="f_title" placeholder="Investor buys downtown tower"></label>
+        </div>
+        <label>Image URL<input id="f_image" placeholder="https://… (optional)"></label>
+        <label>Summary / notes<textarea id="f_summary" placeholder="Optional details about the transaction"></textarea></label>
+      </form>
+      <div class="mf">
+        <span class="msg" id="mMsg"></span>
+        <span><button class="btn ghost" id="mCancel">Cancel</button> <button class="btn" id="mSave">Save deal</button></span>
+      </div>
+    </div>
+  </div>
+
 <script>
 const $=s=>document.querySelector(s);
-let ALL=[], fTxn='', fType='', fQ='', sVal=(localStorage.getItem('rentv.deals.sort')||'newest');
-const TXNS=['Sale','Lease','Financing','Development'];
+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};
+let ALL=[], fTxn='', fType='', fQ='',
+    sVal=(localStorage.getItem('rentv.deals.sort')||'newest'),
+    dens=(localStorage.getItem('rentv.deals.dens')||'3'),
+    showHidden=(localStorage.getItem('rentv.deals.hidden')==='1');
+const TXNS=['Sale','Lease','Financing','Development','Acquisition'];
 const TYPES=['Multifamily','Office','Industrial','Retail','Hospitality','Mixed-Use','Land'];
-function esc(s){return (s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
-// parse "$12.5M" / "1,250,000" → number (‑1 = no parseable amount, sorts last)
+
+function esc(s){return String(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
+
 function amtNum(d){
   const s=String(d.amount_label||d.amount||'').replace(/[,\s$]/g,'');
-  const m=s.match(/([\d.]+)\s*([KMB])?/i);
-  if(!m) return -1;
-  let n=parseFloat(m[1]); if(isNaN(n)) return -1;
-  const u=(m[2]||'').toUpperCase();
-  if(u==='K')n*=1e3; else if(u==='M')n*=1e6; else if(u==='B')n*=1e9;
-  return n;
+  const m=s.match(/([\d.]+)\s*([MmKkBb]?)/);
+  if(!m)return -1;
+  let n=parseFloat(m[1]);
+  const u=m[2].toUpperCase();
+  if(u==='B')n*=1e9;else if(u==='M')n*=1e6;else if(u==='K')n*=1e3;
+  return isNaN(n)?-1:n;
 }
-function sortRows(rows){
-  const r=rows.slice();
-  if(sVal==='price-desc') r.sort((a,b)=>amtNum(b)-amtNum(a));
-  else if(sVal==='price-asc') r.sort((a,b)=>{const x=amtNum(a),y=amtNum(b);if(x<0)return 1;if(y<0)return -1;return x-y;});
-  else if(sVal==='type') r.sort((a,b)=>(a.property_type||'').localeCompare(b.property_type||''));
-  else if(sVal==='loc') r.sort((a,b)=>[a.city,a.state].filter(Boolean).join(', ').localeCompare([b.city,b.state].filter(Boolean).join(', ')));
-  else if(sVal==='title') r.sort((a,b)=>(a.title||'').localeCompare(b.title||''));
-  // 'newest' = natural feed order (ALL is already newest-first)
-  return r;
+
+function dateOf(d){
+  if(d.close_date){const m=String(d.close_date).match(/^(\d{4})-(\d{2})-(\d{2})/);if(m)return new Date(Date.UTC(+m[1],+m[2]-1,+m[3]));}
+  const m=String(d.image||'').match(/([A-Z][a-z]+?)(\d{1,2})(\d{4})\.(?:jpg|jpeg|png|webp)/);if(!m)return null;const mo=MONTHS[m[1].slice(0,3)];if(mo==null)return null;return new Date(Date.UTC(+m[3],mo,+m[2]));
 }
-function pills(host,list,cur,pick){
-  host.innerHTML=`<button class="pill${cur===''?' on':''}" data-v="">All</button>`+
-    list.map(v=>`<button class="pill${cur===v?' on':''}" data-v="${v}">${v}</button>`).join('');
-  host.querySelectorAll('.pill').forEach(b=>b.onclick=()=>{pick(b.dataset.v);});
+const fmtDate=dt=>dt?dt.toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}):'';
+const isManual=d=>d._manual||String(d.id||'').startsWith('md');
+
+function pillRow(hostId,list,cur,pick){
+  const host=$(('#'+hostId));
+  host.innerHTML='<button class="pill'+(cur===''?' on':'')+'" data-v="">All</button>'+
+    list.map(v=>`<button class="pill${cur===v?' on':''}" data-v="${esc(v)}">${esc(v)}</button>`).join('');
+  host.querySelectorAll('.pill').forEach(b=>b.onclick=()=>pick(b.dataset.v));
 }
+
+function sorted(list){
+  return list.slice().sort((a,b)=>{
+    if(sVal==='price-desc')return amtNum(b)-amtNum(a);
+    if(sVal==='price-asc'){const x=amtNum(a),y=amtNum(b);return (x<0?1e18:x)-(y<0?1e18:y);}
+    if(sVal==='type')return (a.property_type||'').localeCompare(b.property_type||'');
+    if(sVal==='loc')return `${a.city} ${a.state}`.localeCompare(`${b.city} ${b.state}`);
+    if(sVal==='title')return (a.title||'').localeCompare(b.title||'');
+    // newest = natural feed order (ALL already newest-first); stable fallback by dateOf
+    const da=dateOf(a),db=dateOf(b);return (db?db.getTime():0)-(da?da.getTime():0);
+  });
+}
+
 function render(){
   let rows=ALL.filter(d=>
-    (!fTxn||d.txn_type===fTxn)&&(!fType||d.property_type===fType)&&
-    (!fQ||`${d.title} ${d.city} ${d.state} ${d.summary}`.toLowerCase().includes(fQ)));
-  rows=sortRows(rows);
-  $('#count').textContent=`${rows.length} listing${rows.length===1?'':'s'}${fTxn?' · '+fTxn:''}${fType?' · '+fType:''}`;
+    (!fTxn||d.txn_type===fTxn)&&
+    (!fType||d.property_type===fType)&&
+    (!fQ||`${d.address} ${d.title} ${d.city} ${d.state} ${d.property_type} ${d.buyer} ${d.seller} ${d.broker} ${d.summary}`.toLowerCase().includes(fQ))
+  );
+  rows=sorted(rows);
+  document.documentElement.style.setProperty('--cols',dens);
+  const manC=ALL.filter(isManual).length, hidC=ALL.filter(d=>d._hidden).length;
+  $('#count').textContent=`${rows.length} deal${rows.length===1?'':'s'}${fTxn?' · '+fTxn:''}${fType?' · '+fType:''} · ${manC} manual${hidC?` · ${hidC} hidden`:''}`;
   $('#grid').innerHTML=rows.length?rows.map(d=>{
     const loc=[d.city,d.state].filter(Boolean).join(', ');
     const ph=d.image?`background-image:url('${esc(d.image)}')`:'';
-    // HARD RULE: never link out to rentv.com. Open the story in OUR in-app reader (/news/:id → article.html).
-    return `<a class="card" href="${d.id?('/news/'+encodeURIComponent(d.id)):'/deals'}">
+    const dt=fmtDate(dateOf(d));
+    const man=isManual(d), hidden=!!d._hidden, edited=!!d._edited;
+    // HARD RULE: never link out to rentv.com; feed/corpus rows link /news/:id in-app only
+    const story=(!man&&d.id)?`<a class="more" href="/news/${encodeURIComponent(d.id)}">Read the full story →</a>`:'<span class="more" style="color:var(--sub)">Manual entry</span>';
+    return `<div class="card ${hidden?'hid':''} ${man?'man':''}" data-id="${esc(String(d.id))}">
       <div class="ph" style="${ph}">
-        <div class="badges"><span class="badge txn">${esc(d.txn_type)}</span><span class="badge">${esc(d.property_type)}</span></div>
+        <div class="badges">
+          ${d.txn_type?`<span class="badge txn">${esc(d.txn_type)}</span>`:'<span class="badge txn">Deal</span>'}
+          ${d.property_type?`<span class="badge">${esc(d.property_type)}</span>`:''}
+          ${man?'<span class="badge man">Manual</span>':''}
+          ${edited?'<span class="badge edt">Edited</span>':''}
+          ${hidden?'<span class="badge hid">Hidden</span>':''}
+        </div>
+        ${dt?`<span class="date">🗓 ${esc(dt)}</span>`:''}
         ${d.amount_label?`<span class="amt">${esc(d.amount_label)}</span>`:''}
       </div>
       <div class="body">
-        <h3>${esc(d.title)}</h3>
-        <div class="meta">${loc?`<span>📍 <b>${esc(loc)}</b></span>`:''}${d.size_label?`<span>📐 ${esc(d.size_label)}</span>`:''}${d.year_built?`<span>🏗 ${d.year_built}</span>`:''}${d.occupancy_pct!=null?`<span>${d.occupancy_pct}% leased</span>`:''}</div>
-        ${d.summary?`<div class="sum">${esc(d.summary)}</div>`:''}
-        <div class="more">Read the full deal →</div>
-      </div></a>`;
-  }).join(''):'<div class="empty">No listings match these filters yet. Clear them or check back — the feed refreshes every 20 minutes.</div>';
+        <div class="addr">${esc(d.address||d.title)}</div>
+        ${loc?`<div class="meta"><span>📍 <b>${esc(loc)}</b></span>${d.size_label?`<span>📐 ${esc(d.size_label)}</span>`:''}${d.year_built?`<span>🏗 ${esc(d.year_built)}</span>`:''}</div>`:''}
+        ${(d.buyer||d.seller||d.broker)?`<div class="meta parties">${d.buyer?`<span title="Buyer">🟢 <b>${esc(d.buyer)}</b></span>`:''}${d.seller?`<span title="Seller">🔴 ${esc(d.seller)}</span>`:''}${d.broker?`<span title="Broker">🤝 ${esc(d.broker)}</span>`:''}</div>`:''}
+        ${(d.address&&d.title)?`<h3>${esc(d.title)}</h3>`:''}
+        <div class="cardfoot">
+          ${story}
+          <div class="acts">
+            <button data-act="edit">Edit</button>
+            ${hidden?'<button class="res" data-act="restore">Restore</button>':`<button class="del" data-act="del">${man?'Delete':'Hide'}</button>`}
+          </div>
+        </div>
+      </div></div>`;
+  }).join(''):'<div class="empty">No deals match your filters yet. Add one with + Add deal, or clear the filters.</div>';
+
+  // wire per-card actions — buttons are NOT inside an <a>, so no navigation side-effects
+  $('#grid').querySelectorAll('.card .acts button').forEach(b=>{
+    b.onclick=()=>{
+      const card=b.closest('.card'), id=card.dataset.id, act=b.dataset.act;
+      if(act==='edit')return openEdit(ALL.find(x=>String(x.id)===String(id)));
+      if(act==='del')return removeDeal(id);
+      if(act==='restore')return restoreDeal(id);
+    };
+  });
+}
+
+// ── editor modal ──
+const F=['id','txn_type','address','city','state','property_type','amount_label','close_date','size_label','year_built','title','image','summary'];
+function openAdd(){
+  $('#mTitle').textContent='Add deal'; $('#mMsg').textContent='';
+  F.forEach(k=>{const el=$('#f_'+k);if(el)el.value='';});
+  $('#ov').classList.add('on'); $('#f_txn_type').focus();
 }
-(async()=>{
+function openEdit(d){
+  if(!d)return; $('#mTitle').textContent='Edit deal'; $('#mMsg').textContent='';
+  F.forEach(k=>{const el=$('#f_'+k);if(el)el.value=d[k]||'';});
+  if(!d.close_date){const dt=dateOf(d);if(dt)$('#f_close_date').value=dt.toISOString().slice(0,10);}
+  $('#ov').classList.add('on'); $('#f_txn_type').focus();
+}
+function closeModal(){$('#ov').classList.remove('on');}
+
+async function saveDeal(){
+  const id=$('#f_id').value.trim();
+  const body={}; F.filter(k=>k!=='id').forEach(k=>body[k]=$('#f_'+k).value.trim());
+  if(!body.address&&!body.title){$('#mMsg').textContent='Address or title is required.';return;}
+  $('#mSave').disabled=true; $('#mMsg').textContent='Saving…';
   try{
-    const d=await (await fetch('/api/deals')).json();
-    ALL=d.deals||[];
-    const ts=d.fetched_at&&new Date(d.fetched_at);
-    $('#fresh').textContent=(ts&&!isNaN(ts))?('Deal flow as of '+ts.toLocaleString()+' · source: RENTV.com'):'';
-  }catch(e){ $('#fresh').textContent='Could not load deal listings — reload to retry.'; }
-  const pickTxn=v=>{fTxn=v;pills($('#txns'),TXNS,fTxn,pickTxn);render();};
-  const pickType=v=>{fType=v;pills($('#types'),TYPES,fType,pickType);render();};
-  pills($('#txns'),TXNS,fTxn,pickTxn);
-  pills($('#types'),TYPES,fType,pickType);
-  $('#q').oninput=e=>{fQ=e.target.value.trim().toLowerCase();render();};
-  // sort + density, persisted (HARD RULE: every data grid gets both)
-  const selSort=$('#sort'), selDens=$('#dens');
-  selSort.value=sVal;
-  selSort.onchange=()=>{sVal=selSort.value;localStorage.setItem('rentv.deals.sort',sVal);render();};
-  const cols0=localStorage.getItem('rentv.deals.cols')||'3';
-  selDens.value=cols0; document.documentElement.style.setProperty('--cols',cols0);
-  selDens.oninput=()=>{document.documentElement.style.setProperty('--cols',selDens.value);localStorage.setItem('rentv.deals.cols',selDens.value);};
-  render();
-})();
+    const url=id?('/api/deals-manage/'+encodeURIComponent(id)):'/api/deals-manage';
+    const r=await fetch(url,{method:id?'PUT':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
+    const j=await r.json();
+    if(!r.ok||!j.ok)throw new Error(j.error||('HTTP '+r.status));
+    closeModal(); await load();
+  }catch(e){$('#mMsg').textContent='Failed: '+e.message;}
+  finally{$('#mSave').disabled=false;}
+}
+
+async function removeDeal(id){
+  const man=isManual({id});
+  if(!confirm(man?'Delete this manual deal permanently?':'Hide this deal from the list? You can restore it with "Show hidden".'))return;
+  try{
+    const r=await fetch('/api/deals-manage/'+encodeURIComponent(id),{method:'DELETE'});
+    const j=await r.json(); if(!r.ok||!j.ok)throw new Error(j.error||('HTTP '+r.status));
+    await load();
+  }catch(e){alert('Failed: '+e.message);}
+}
+
+async function restoreDeal(id){
+  try{
+    const r=await fetch('/api/deals-manage/'+encodeURIComponent(id)+'/restore',{method:'POST'});
+    const j=await r.json(); if(!r.ok||!j.ok)throw new Error(j.error||('HTTP '+r.status));
+    await load();
+  }catch(e){alert('Failed: '+e.message);}
+}
+
+// init persistent controls
+$('#sort').value=sVal; $('#dens').value=dens; $('#showHidden').checked=showHidden;
+$('#sort').onchange=e=>{sVal=e.target.value;localStorage.setItem('rentv.deals.sort',sVal);render();};
+$('#dens').oninput=e=>{dens=e.target.value;localStorage.setItem('rentv.deals.dens',dens);render();};
+$('#q').oninput=e=>{fQ=e.target.value.toLowerCase().trim();render();};
+$('#showHidden').onchange=e=>{showHidden=e.target.checked;localStorage.setItem('rentv.deals.hidden',showHidden?'1':'0');load();};
+$('#addBtn').onclick=openAdd;
+$('#mClose').onclick=closeModal; $('#mCancel').onclick=closeModal; $('#mSave').onclick=saveDeal;
+$('#ov').onclick=e=>{if(e.target===$('#ov'))closeModal();};
+document.addEventListener('keydown',e=>{if(e.key==='Escape'&&$('#ov').classList.contains('on'))closeModal();});
+
+function renderPills(){
+  pillRow('txns',TXNS,fTxn,v=>{fTxn=v;renderPills();render();});
+  pillRow('types',TYPES,fType,v=>{fType=v;renderPills();render();});
+}
+
+async function load(){
+  try{
+    const r=await fetch('/api/deals-manage'+(showHidden?'?include_hidden=1':''));
+    if(r.status===403){$('#grid').innerHTML='<div class="empty">Admin login required — this is a backend-only page.</div>';return;}
+    const data=await r.json();
+    ALL=data.deals||[];
+    $('#fresh').textContent=`${data.count??ALL.length} deals · ${data.from_feed||0} live feed · ${data.from_corpus||0} article archive · ${data.manual||0} manual · ${data.hidden||0} hidden`;
+    render();
+  }catch(e){$('#grid').innerHTML='<div class="empty">Failed to load deals: '+esc(e.message)+'</div>';}
+}
+
+(async()=>{renderPills();await load();})();
 </script>
 </body>
 </html>
diff --git a/server.js b/server.js
index 05ed5e2c..f6ea55a9 100644
--- a/server.js
+++ b/server.js
@@ -94,7 +94,7 @@ footer:not(#rentv-footer){display:none!important}
   <div class="rf-bar"><span>© 2026 RENTV.com — Commercial Real Estate News across the Western U.S.</span><span>All rights reserved.</span></div>
 </footer>`;
 // Internal/admin shells never get the customer footer.
-const INTERNAL_PAGE = /(^|\/)(admin|versions|consulting|press|desk|desk-admin|audience|social|backend|deals-log|closings|news-playbook|re-search|summit-leads)(\.html)?$|\/(admin|versions|consulting|press)\//i;
+const INTERNAL_PAGE = /(^|\/)(admin|versions|consulting|press|desk|desk-admin|audience|social|backend|deals-log|closings|deals|news-playbook|re-search|summit-leads)(\.html)?$|\/(admin|versions|consulting|press)\//i;
 function sendPage(res, absFile) {
   let html;
   try { html = fs.readFileSync(absFile, 'utf8'); }
@@ -115,7 +115,7 @@ function sendPage(res, absFile) {
   // hide their nav links from user-tier so the front page shows no dead links (zero-flash,
   // server-rendered off req.role — no dependency on JS or per-page nav edits).
   if (res.req && res.req.role !== 'admin' && html.indexOf('rentv-hide-admin-nav') === -1) {
-    const hideCss = '<style id="rentv-hide-admin-nav">a[href^="/la-commercial"],a[href^="/pulse"],a[href^="/sector"],a[href^="/hub"],a[href^="/brief"],a[href^="/closings"]{display:none!important}</style>';
+    const hideCss = '<style id="rentv-hide-admin-nav">a[href^="/la-commercial"],a[href^="/pulse"],a[href^="/sector"],a[href^="/hub"],a[href^="/brief"],a[href^="/closings"],a[href^="/deals"]{display:none!important}</style>';
     html = html.indexOf('</head>') !== -1 ? html.replace('</head>', hideCss + '</head>') : hideCss + html;
   }
   res.type('html').send(html);
@@ -409,6 +409,107 @@ app.post('/api/closings/:id/restore', adminOnly, (req, res) => {
   res.json({ ok: true, restored: true });
 });
 
+// ── DEALS: editable override layer over the FULL reported-transaction set (TK-10255, Steve
+//    2026-08-05). Same pattern as the closings block above, but ALL txn types (Sale/Lease/
+//    Financing/Development/Acquisition) and txn_type is itself editable. BACKEND-ONLY.
+//    NOTE: the PUBLIC /api/deals endpoint is UNCHANGED (brief/compare/metros/sector/watchlist
+//    widgets depend on it) — this admin surface is a SEPARATE /api/deals-manage endpoint.
+//    Base = deals.json (all txns) ∪ article corpus (all txns, listings filtered) deduped by id,
+//    with the deals-overrides.json manual layer on top. ──
+const DEALS_OV = 'deals-overrides.json';
+const DEALS_FIELDS = ['txn_type', 'title', 'address', 'city', 'state', 'property_type', 'amount_label', 'size_label', 'year_built', 'close_date', 'image', 'summary'];
+const readDealsOv = () => readJSON(DEALS_OV, { adds: [], edits: {}, hides: [], updated_at: null });
+const writeDealsOv = (o) => fs.writeFileSync(path.join(DATA, DEALS_OV), JSON.stringify(o, null, 1));
+// Map corpus articles (ALL txn types) → deal rows; drop "offered for sale"/pending listings.
+function corpusDeals() {
+  return corpus()
+    .filter((a) => a.txn_type && !CLOSINGS_NONCLOSED.test(a.title || ''))
+    .map((a) => {
+      const amt = Number(a.amount) || 0;
+      const isSale = String(a.txn_type).toLowerCase() === 'sale';
+      return {
+        id: String(a.id), title: a.title || '', url: a.url || '', image: a.image || '', txn_type: a.txn_type || 'Sale',
+        property_type: a.property_type || '', city: a.city || '', state: a.state || '',
+        amount: a.amount || null, amount_label: (isSale && amt > 0 && amt < 1_000_000) ? '' : (a.amount_label || ''),
+        size_label: a.size_label || '', units: a.units || null, sqft: a.sqft || null,
+        address: null, close_date: a.date || '', year_built: null,
+        buyer: a.buyer || '', seller: a.seller || '', broker: a.broker || '', summary: a.summary || '',
+      };
+    });
+}
+app.get('/api/deals-manage', adminOnly, (req, r) => {
+  const feed = readJSON('deals.json', { deals: [], fetched_at: null });
+  const ov = readDealsOv();
+  const hides = new Set((ov.hides || []).map(String));
+  const edits = ov.edits || {};
+  const includeHidden = req.query.include_hidden === '1' || req.query.include_hidden === 'true';
+  const feedDeals = (feed.deals || []);
+  const feedIds = new Set(feedDeals.map((d) => String(d.id)));
+  const corpusExtra = corpusDeals().filter((a) => !feedIds.has(String(a.id)));
+  const base = [...feedDeals, ...corpusExtra];
+  const feedRows = base.map((d) => {
+    const e = edits[String(d.id)];
+    const row = e ? { ...d, ...e, _edited: true } : { ...d };
+    row._hidden = hides.has(String(d.id));
+    row._corpus = feedIds.has(String(d.id)) ? undefined : true;
+    return row;
+  }).filter((d) => includeHidden || !d._hidden);
+  const adds = (ov.adds || []).map((a) => ({ ...a, _manual: true }));
+  const deals = [...adds, ...feedRows];
+  r.json({ source: 'feed+corpus+overrides', fetched_at: feed.fetched_at, updated_at: ov.updated_at,
+    from_feed: feedDeals.length, from_corpus: corpusExtra.length,
+    manual: adds.length, hidden: (ov.hides || []).length, count: deals.length, deals });
+});
+app.post('/api/deals-manage', adminOnly, (req, res) => {
+  const b = req.body || {};
+  if (!clean(b.address) && !clean(b.title)) return res.status(400).json({ ok: false, error: 'address or title required' });
+  const now = new Date().toISOString();
+  const item = { id: 'md' + Date.now().toString(36), url: '', manual: true, created_at: now, updated_at: now };
+  for (const k of DEALS_FIELDS) item[k] = clean(b[k], k === 'summary' ? 2000 : 500);
+  if (!item.txn_type) item.txn_type = 'Sale';
+  if (!item.title) item.title = item.address;
+  const ov = readDealsOv(); ov.adds = ov.adds || []; ov.adds.push(item); ov.updated_at = now;
+  try { writeDealsOv(ov); } catch { return res.status(500).json({ ok: false, error: 'save failed' }); }
+  res.json({ ok: true, item });
+});
+app.put('/api/deals-manage/:id', adminOnly, (req, res) => {
+  const id = String(req.params.id), b = req.body || {}, now = new Date().toISOString();
+  const ov = readDealsOv();
+  const ai = (ov.adds || []).findIndex((a) => String(a.id) === id);
+  if (ai >= 0) {
+    for (const k of DEALS_FIELDS) if (b[k] != null) ov.adds[ai][k] = clean(b[k], k === 'summary' ? 2000 : 500);
+    ov.adds[ai].updated_at = now; ov.updated_at = now;
+    try { writeDealsOv(ov); } catch { return res.status(500).json({ ok: false, error: 'save failed' }); }
+    return res.json({ ok: true, item: ov.adds[ai] });
+  }
+  ov.edits = ov.edits || {}; const e = ov.edits[id] || {};
+  for (const k of DEALS_FIELDS) if (b[k] != null) e[k] = clean(b[k], k === 'summary' ? 2000 : 500);
+  e._updated_at = now; ov.edits[id] = e; ov.updated_at = now;
+  try { writeDealsOv(ov); } catch { return res.status(500).json({ ok: false, error: 'save failed' }); }
+  res.json({ ok: true, edit: e });
+});
+app.delete('/api/deals-manage/:id', adminOnly, (req, res) => {
+  const id = String(req.params.id), now = new Date().toISOString();
+  const ov = readDealsOv();
+  const before = (ov.adds || []).length;
+  ov.adds = (ov.adds || []).filter((a) => String(a.id) !== id);
+  if (ov.adds.length < before) {
+    ov.updated_at = now;
+    try { writeDealsOv(ov); } catch { return res.status(500).json({ ok: false, error: 'save failed' }); }
+    return res.json({ ok: true, removed: true });
+  }
+  ov.hides = Array.from(new Set([...(ov.hides || []).map(String), id])); ov.updated_at = now;
+  try { writeDealsOv(ov); } catch { return res.status(500).json({ ok: false, error: 'save failed' }); }
+  res.json({ ok: true, hidden: true });
+});
+app.post('/api/deals-manage/:id/restore', adminOnly, (req, res) => {
+  const id = String(req.params.id), now = new Date().toISOString();
+  const ov = readDealsOv();
+  ov.hides = (ov.hides || []).map(String).filter((h) => h !== id); ov.updated_at = now;
+  try { writeDealsOv(ov); } catch { return res.status(500).json({ ok: false, error: 'save failed' }); }
+  res.json({ ok: true, restored: true });
+});
+
 // ── Newsletter capture: append-only local JSONL (NO external send-to-list). ──
 const SUBS = path.join(DATA, 'subscribers.jsonl');
 const MAX_SUBS = 50000; // hard ceiling — guards against disk-fill / append abuse
@@ -1008,7 +1109,10 @@ SECTIONS.forEach(s => app.get('/' + s, (_q, r) => {
   const h = sectionHub(s);
   return r.redirect(301, h ? '/topic/' + h.slug : '/?cat=' + s.charAt(0).toUpperCase() + s.slice(1));
 }));
-app.get('/deals', (_q, r) => sendPage(r, path.join(PUB, 'deals.html')));
+// Deals page → BACKEND-ONLY editor (TK-10255, Steve 2026-08-05), same pattern as /closings.
+// The PUBLIC /api/deals endpoint stays open (brief/compare/metros/sector/watchlist widgets use it);
+// this admin page reads /api/deals-manage (feed+corpus+overrides, all txn types) instead.
+app.get('/deals', adminOnly, (_q, r) => sendPage(r, path.join(PUB, 'deals.html')));
 app.get('/news/:id', (_q, r) => sendPage(r, path.join(PUB, 'article.html')));
 // The REview — the folded-in video platform, served natively in the RENTV shell
 app.get('/review', (_q, r) => sendPage(r, path.join(PUB, 'review.html')));

← 279335d4 closings: buyer/seller/broker are now clickable → filter the  ·  back to Rentv  ·  Cody-gate: trim party value before filtering (whitespace-saf 2ec421b3 →