[object Object]

← back to Commercialrealestate

just-listed: Cody-gate fixes — recency join by address (new-only was broken), theme-aware --onacc contrast, drip-timer cleanup on view switch, lock broker contact cols in all views, sanitize Null/website, cap TV retry

8cdf838a3a3d76ac2ece62cd5a964b2a578f4287 · 2026-08-18 17:31:59 -0700 · Steve Abrams

Files touched

Diff

commit 8cdf838a3a3d76ac2ece62cd5a964b2a578f4287
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Aug 18 17:31:59 2026 -0700

    just-listed: Cody-gate fixes — recency join by address (new-only was broken), theme-aware --onacc contrast, drip-timer cleanup on view switch, lock broker contact cols in all views, sanitize Null/website, cap TV retry
---
 public/fha-leads.html   |  2 +-
 public/just-listed.html | 54 ++++++++++++++++++++++++++++++-------------------
 scripts/serve.js        | 11 ++++++----
 3 files changed, 41 insertions(+), 26 deletions(-)

diff --git a/public/fha-leads.html b/public/fha-leads.html
index dc16d63..5eb38db 100644
--- a/public/fha-leads.html
+++ b/public/fha-leads.html
@@ -190,7 +190,7 @@ $('#dens').value=dens;
 // don't pass a sortSel — ThreeView just adds the view modes + the compact list.
 TV=ThreeView.mount({
   seg:'#viewseg', gridMount:'#gridView', listMount:'#listView', tableMount:'#tableView',
-  getRows:()=>CURRENT,
+  getRows:()=>CURRENT.slice(0,600),   // bound every view's DOM to the top-600 by current sort (count still reports the full match total)
   fields:COLS.filter(c=>!['property','city','state'].includes(c.k)).map(c=>({k:c.k,l:c.l,html:true,calc:r=>cell(r,c)})),
   title:r=>({name:r.property||'—', sub:(r.city||'')+(r.state?', '+r.state:'')}),
   badgeHtml:r=>r.belowMarket?'<span class="pill gold">ASSUMABLE ◆</span>':'',
diff --git a/public/just-listed.html b/public/just-listed.html
index e7a5023..78f8769 100644
--- a/public/just-listed.html
+++ b/public/just-listed.html
@@ -14,7 +14,7 @@
 a{color:var(--blue)}
 header{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;flex-wrap:wrap;gap:10px;align-items:center}
 h1{font-size:17px;margin:0}h1 b{color:var(--ink)}
-.badge{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#0e1116;background:var(--acc);padding:3px 8px;border-radius:6px}
+.badge{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:var(--onacc);background:var(--acc);padding:3px 8px;border-radius:6px}
 .nav{display:flex;align-items:center;gap:2px;flex-wrap:wrap}
 .nav a{color:var(--blue);text-decoration:none;font-size:12px;margin-left:12px}
 /* ticker */
@@ -43,8 +43,8 @@ h1{font-size:17px;margin:0}h1 b{color:var(--ink)}
 .panel h4 .n{color:var(--mut);font-weight:600;font-size:10px}
 .chips{display:flex;flex-wrap:wrap;gap:5px}
 .chip{border:1px solid var(--line);border-radius:20px;padding:3px 9px;font-size:11px;cursor:pointer;background:var(--bg2,var(--card));user-select:none;color:var(--ink)}
-.chip.active{background:var(--blue);color:#0e1116;border-color:var(--blue);font-weight:600}
-.chip .ct{color:var(--mut);margin-left:4px;font-size:10px}.chip.active .ct{color:#0e1116}
+.chip.active{background:var(--blue);color:var(--onacc);border-color:var(--blue);font-weight:600}
+.chip .ct{color:var(--mut);margin-left:4px;font-size:10px}.chip.active .ct{color:var(--onacc)}
 .rail input[type=number]{width:100%;background:var(--bg2,var(--card));border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:7px 9px;font-size:12px}
 /* column-picker inside the rail */
 .colgrid{display:grid;grid-template-columns:1fr 1fr;gap:1px 12px}
@@ -59,7 +59,7 @@ h1{font-size:17px;margin:0}h1 b{color:var(--ink)}
 select{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:7px 9px;font-size:12px}
 .btn{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:7px 11px;font-size:12px;cursor:pointer;white-space:nowrap}
 .btn:hover{border-color:var(--blue)}
-.btn.on{background:var(--blue);color:#0e1116;border-color:var(--blue);font-weight:600}
+.btn.on{background:var(--blue);color:var(--onacc);border-color:var(--blue);font-weight:600}
 .count{color:var(--mut);font-size:12px}
 .prov{font-size:11px;color:var(--mut);margin:0 0 12px;line-height:1.5}
 .stat{display:inline-block;background:var(--card);border:1px solid var(--line);border-radius:9px;padding:7px 12px;margin:0 8px 10px 0}
@@ -68,7 +68,7 @@ select{background:var(--card);border:1px solid var(--line);color:var(--ink);bord
 .st.active{background:rgba(63,185,80,.16);color:var(--acc)}
 .st.pending{background:rgba(255,166,0,.16);color:var(--gold)}
 .st.other{background:rgba(139,148,158,.16);color:var(--mut)}
-.newpill{font-size:9px;font-weight:800;letter-spacing:.4px;color:#0e1116;background:var(--acc);padding:2px 6px;border-radius:5px;text-transform:uppercase}
+.newpill{font-size:9px;font-weight:800;letter-spacing:.4px;color:var(--onacc);background:var(--acc);padding:2px 6px;border-radius:5px;text-transform:uppercase}
 /* ── inline chat bar (in the middle of the page) ── */
 .chatbar{border:1px solid var(--line);border-radius:12px;background:var(--card);margin:0 0 12px;overflow:hidden}
 .chatbar .ch-hd{display:flex;align-items:center;gap:8px;padding:9px 12px;cursor:pointer;user-select:none;border-bottom:1px solid transparent}
@@ -80,7 +80,7 @@ select{background:var(--card);border:1px solid var(--line);color:var(--ink);bord
 .chatbar.open .ch-body{display:block}
 .chatlog{max-height:230px;overflow:auto;display:flex;flex-direction:column;gap:8px;margin-bottom:9px}
 .msg{padding:8px 11px;border-radius:10px;font-size:12.5px;line-height:1.45;max-width:88%;white-space:pre-wrap}
-.msg.u{align-self:flex-end;background:var(--blue);color:#0e1116}
+.msg.u{align-self:flex-end;background:var(--blue);color:var(--onacc)}
 .msg.a{align-self:flex-start;background:var(--bg2,rgba(255,255,255,.05));border:1px solid var(--line)}
 .msg.a.think{color:var(--mut);font-style:italic}
 .chatform{display:flex;gap:8px}
@@ -142,6 +142,7 @@ td a.f:hover{text-decoration:underline}
 </style>  <link rel="stylesheet" href="/three-view.css">
   <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
   <link rel="stylesheet" href="/crcp-theme.css"><!-- CRCP day/night tokens: load LAST so it wins -->
+  <style>/* theme-aware "text on accent" so chips/buttons/pills/chat bubbles pass contrast in BOTH themes (Cody gate): accents are LIGHT in dark mode (black text), DARK in light mode (white text) */:root{--onacc:#0e1116}html[data-theme="light"]{--onacc:#ffffff}</style>
 </head><body>
 <header>
   <h1><b>Just Listed</b> · Active Inventory</h1>
@@ -204,7 +205,7 @@ let DATA=[],META={},q='',sortKey='price',sortDir=-1,ME=null,TV=null;
 let BROKERS={};           // firm_key/lc-firm -> {name,firm,phone,email,website,linkedin}  (weak fallback)
 let ADDRC={};             // normalized-address -> {name,phone,email,website}  (REAL per-listing agent, primary)
 let SEEN={};              // localStorage first-seen ledger (id -> iso)
-let DATES={};             // /api/listing-dates (id -> iso created_at)
+let DATES={};             // /api/listing-dates rows -> normalized-address -> iso created_at (ids don't overlap; join on address)
 let onlyNew=false;
 const NEW_DAYS=14;
 let dens=(function(){const v=parseInt(localStorage.getItem('jlDens'),10);return(v>=1&&v<=8)?v:4;})();
@@ -216,6 +217,8 @@ const statusShort=s=>String(s||'—').replace(/\s*\(.*\)$/,'').trim();
 const norm=s=>String(s||'').toLowerCase().replace(/[^a-z0-9]+/g,'').replace(/(inc|llc|corp|corporation|company|co|realty|realestate|group|properties|partners|associates)$/,'');
 // address normalizer — drops street-type + directional words so listings.json ⇄ CRCP DB addresses join
 const na=s=>String(s||'').toLowerCase().replace(/\b(street|st|avenue|ave|boulevard|blvd|road|rd|drive|dr|lane|ln|place|pl|court|ct|suite|ste|unit|#|north|south|east|west|n|s|e|w)\b/g,'').replace(/[^a-z0-9]+/g,'');
+const clean=v=>{const s=(v==null?'':String(v)).trim();return(!s||/^null/i.test(s))?null:s;}; // drop DB "Null00709"-style stringified nulls
+const weburl=w=>{w=clean(w);return w?(/^https?:\/\//i.test(w)?w:'http://'+w):null;}; // ensure a real scheme so href isn't a broken relative path
 
 // ── broker contact join (Steve HARD RULE: always show broker phone + email) ──
 // Priority: (1) REAL agent matched by property address from CRCP's broker graph, then
@@ -228,7 +231,7 @@ function brokerFor(r){
   return null;
 }
 // first-seen recency: DB created_at if present, else this-viewer's first-seen ledger
-function firstSeen(r){ return DATES[r.id] || SEEN[r.id] || null; }
+function firstSeen(r){ return DATES[na(r.address)] || SEEN[r.id] || null; }
 function isNew(r){
   const fs=firstSeen(r); if(!fs) return false;
   const age=(Date.now()-new Date(fs).getTime())/86400000;
@@ -249,8 +252,8 @@ const COLS=[
   {k:'year_built',label:'Built',on:false,num:true,render:r=>r.year_built||'—'},
   {k:'status',label:'Status',on:true,render:r=>`<span class="st ${statusClass(r.status)}">${esc(statusShort(r.status))}</span>${isNew(r)?' <span class="newpill">new</span>':''}`},
   {k:'firm',label:'Firm',on:true,render:r=>esc(String(r.firm||'—').slice(0,42))},
-  {k:'broker_phone',label:'Broker phone',on:true,val:r=>{const b=brokerFor(r);return b&&b.phone||'';},render:r=>{const b=brokerFor(r);return b&&b.phone?`<a class="f" href="tel:${esc(b.phone.replace(/[^0-9+]/g,''))}" onclick="event.stopPropagation()">${esc(b.phone)}</a>`:'<span style="color:var(--mut)">via source ↗</span>';}},
-  {k:'broker_email',label:'Broker email',on:true,val:r=>{const b=brokerFor(r);return b&&b.email||'';},render:r=>{const b=brokerFor(r);return b&&b.email?`<a class="f" href="mailto:${esc(b.email)}" onclick="event.stopPropagation()">${esc(b.email)}</a>`:'<span style="color:var(--mut)">via source ↗</span>';}},
+  {k:'broker_phone',label:'Broker phone',on:true,lock:true,val:r=>{const b=brokerFor(r);return b&&b.phone||'';},render:r=>{const b=brokerFor(r);return b&&b.phone?`<a class="f" href="tel:${esc(b.phone.replace(/[^0-9+]/g,''))}" onclick="event.stopPropagation()">${esc(b.phone)}</a>`:'<span style="color:var(--mut)">via source ↗</span>';}},
+  {k:'broker_email',label:'Broker email',on:true,lock:true,val:r=>{const b=brokerFor(r);return b&&b.email||'';},render:r=>{const b=brokerFor(r);return b&&b.email?`<a class="f" href="mailto:${esc(b.email)}" onclick="event.stopPropagation()">${esc(b.email)}</a>`:'<span style="color:var(--mut)">via source ↗</span>';}},
   {k:'rent_control',label:'Rent control',on:false,cls:'wrap',render:r=>esc(r.rent_control||'—')},
   {k:'metro',label:'Metro',on:false,render:r=>esc(r.metro||'—')},
   {k:'verified',label:'Verified',on:false,render:r=>r.verified?'✓':'—'},
@@ -263,6 +266,7 @@ try{
   const savedVis=JSON.parse(localStorage.getItem('jlCols')||'null');
   if(Array.isArray(savedVis)&&savedVis.length)COLS.forEach(c=>c.on=savedVis.includes(c.k));
 }catch(e){}
+COLS.forEach(c=>{if(c.lock)c.on=true;}); // locked columns (broker phone/email) can't be hidden — Steve hard rule, all views
 const visCols=()=>COLS.filter(c=>c.on);
 const sortVal=(r,key)=>{const c=COLS.find(x=>x.k===key);return c&&c.val?c.val(r):r[key];};
 const saveCols=()=>{localStorage.setItem('jlCols',JSON.stringify(COLS.filter(c=>c.on).map(c=>c.k)));localStorage.setItem('jlColOrder',JSON.stringify(COLS.map(c=>c.k)));};
@@ -383,7 +387,8 @@ function renderStats(rows){
     `<div class="stat"><b>${units.toLocaleString()}</b><span>units</span></div>`+
     `<div class="stat"><b>${new Set(rows.map(r=>r.city)).size}</b><span>cities</span></div>`;
   $('#count').textContent=`${rows.length.toLocaleString()} of ${DATA.length.toLocaleString()}`;
-  $('#empty').hidden=rows.length>0;
+  const emp=$('#empty');emp.hidden=rows.length>0;
+  if(rows.length===0)emp.textContent=onlyNew?`No listings first seen in the last ${NEW_DAYS} days. Recency = CRCP capture date where known, else this browser's first-seen history — so new arrivals appear here on the visit after they enter the feed.`:'No listings match.';
 }
 
 // ── ThreeView: Grid / List / Table + all-field sort ──
@@ -400,6 +405,7 @@ function mountThreeView(){
     fvis:k=>{const c=COLS.find(x=>x.k===k);return c?c.on:false;},
     renderGrid, renderTable,
     onSort:(k,dir)=>{ if(k){sortKey=k;sortDir=dir;} render(); },
+    onView:v=>{ if(v!=='grid'&&dripTimer){clearInterval(dripTimer);dripTimer=null;} }, // kill the grid drip when leaving grid
     rowClick:(row)=>openSpec(row),
     storageKey:'jl', defaultView:'grid', views:['grid','list','table'],
   });
@@ -484,8 +490,8 @@ $('#drip').value=dripLvl;$('#dens').value=dens;
 // ── column picker in the rail (remove / restore any column) ──
 function renderColGrid(){
   $('#colGrid').innerHTML=
-    `<div class="ph"><span>${visCols().length}/${COLS.length} shown</span><a id="colAll">all</a></div>`+
-    COLS.map(c=>`<label><input type="checkbox" data-k="${c.k}"${c.on?' checked':''}>${esc(c.label)}</label>`).join('');
+    `<div class="ph"><span>${visCols().length} shown · broker contact locked on</span><a id="colAll">all</a></div>`+
+    COLS.filter(c=>!c.lock).map(c=>`<label><input type="checkbox" data-k="${c.k}"${c.on?' checked':''}>${esc(c.label)}</label>`).join('');
 }
 $('#colGrid').addEventListener('click',e=>{
   if(e.target.id==='colAll'){e.preventDefault();COLS.forEach(c=>c.on=true);renderColGrid();saveCols();render();return;}
@@ -542,8 +548,9 @@ async function loadTicker(){try{const r=await api('/api/ticker');const items=(r&
 loadTicker();
 
 // ── boot: load brokers (contacts) + first-seen dates, then listings ──
+let _tvTries=0;
 function startViews(){
-  if(typeof ThreeView==='undefined'){return setTimeout(startViews,40);} // three-view.js is deferred
+  if(typeof ThreeView==='undefined'){ if(++_tvTries>100){renderGrid(filtered());renderTable(filtered());return;} return setTimeout(startViews,40); } // three-view.js is deferred; cap the retry so a 404 can't spin forever
   mountThreeView();render();
 }
 async function boot(){
@@ -552,8 +559,9 @@ async function boot(){
     const arr=(r&&(r.brokers||r.results||r))||[];
     (Array.isArray(arr)?arr:[]).forEach(b=>{
       const key=norm(b.firm||b.name); if(!key)return;
-      if(!BROKERS[key] || ((b.phone||b.email) && !(BROKERS[key].phone||BROKERS[key].email)))
-        BROKERS[key]={name:b.name,firm:b.firm,phone:b.phone,email:b.email,website:b.website,linkedin:b.linkedin};
+      const ph=clean(b.phone),em=clean(b.email);
+      if(!BROKERS[key] || ((ph||em) && !(BROKERS[key].phone||BROKERS[key].email)))
+        BROKERS[key]={name:clean(b.name),firm:b.firm,phone:ph,email:em,website:weburl(b.website),linkedin:b.linkedin};
     });
   }catch(e){}
   // REAL per-listing agent contact by address (primary source — see brokerFor)
@@ -561,18 +569,22 @@ async function boot(){
     const lb=await api('/api/listing-brokers');
     ((lb&&lb.rows)||[]).forEach(row=>{
       const k=na(row.address); if(!k)return;
-      if(!ADDRC[k] || ((row.phone||row.email) && !(ADDRC[k].phone||ADDRC[k].email)))
-        ADDRC[k]={name:row.name,firm:null,phone:row.phone,email:row.email,website:row.website};
+      const ph=clean(row.phone),em=clean(row.email);
+      if(!ADDRC[k] || ((ph||em) && !(ADDRC[k].phone||ADDRC[k].email)))
+        ADDRC[k]={name:clean(row.name),firm:null,phone:ph,email:em,website:weburl(row.website)};
     });
   }catch(e){}
-  try{ const d=await api('/api/listing-dates'); DATES=(d&&d.dates)||{}; }catch(e){ DATES={}; }
+  try{ const d=await api('/api/listing-dates'); ((d&&d.rows)||[]).forEach(row=>{const k=na(row.address); if(k&&row.created_at) DATES[k]=row.created_at;}); }catch(e){ DATES={}; }
   try{ SEEN=JSON.parse(localStorage.getItem('jlSeen')||'{}'); }catch(e){ SEEN={}; }
 
   fetch('/data/listings.json').then(r=>r.json()).then(d=>{
     META=d.meta||{};
     DATA=(d.listings||[]).filter(l=>String(l.status||'').toLowerCase().startsWith('active'));
-    const nowIso=new Date().toISOString();let changed=false;
-    DATA.forEach(r=>{ if(!SEEN[r.id] && !DATES[r.id]){ SEEN[r.id]=nowIso; changed=true; } });
+    // Honest recency: DB capture date wins. For date-less listings, the FIRST visit establishes a
+    // baseline (stamped far in the past = NOT "new") so we don't flood "New only" with the whole
+    // catalog; only ids that first appear on a LATER visit get stamped "now" and count as new.
+    const nowIso=new Date().toISOString(),baseIso='2000-01-01T00:00:00.000Z',firstRun=!localStorage.getItem('jlSeen');let changed=false;
+    DATA.forEach(r=>{ if(!SEEN[r.id] && !DATES[na(r.address)]){ SEEN[r.id]=firstRun?baseIso:nowIso; changed=true; } });
     if(changed){ try{ localStorage.setItem('jlSeen',JSON.stringify(SEEN)); }catch(e){} }
     $('#srcBadge').textContent=`${DATA.length.toLocaleString()} active`;
     const withContact=DATA.filter(r=>{const b=brokerFor(r);return b&&(b.phone||b.email);}).length;
diff --git a/scripts/serve.js b/scripts/serve.js
index d1bd253..3c73d99 100644
--- a/scripts/serve.js
+++ b/scripts/serve.js
@@ -170,11 +170,14 @@ app.get('/api/agent-sites', (req, res) => res.json({ sites: readAgentSites() }))
 // date, but the `listing` table has created_at. Returns { [listing_id]: iso }. Empty when no local DB.
 app.get('/api/listing-dates', async (req, res) => {
   try {
-    if (!brokerdb) return res.json({ dates: {} });
-    const rows = await brokerdb.pool.query(`SELECT id, created_at FROM listing WHERE created_at IS NOT NULL`).then(r => r.rows);
+    if (!brokerdb) return res.json({ dates: {}, rows: [] });
+    // `dates` is keyed by the listing PK (crx*) — used by ranked.json consumers (mls.html).
+    // `rows` (address + created_at) is ADDED for just-listed.html, whose ids are address-slugs
+    // (zero overlap with crx*), so it joins recency on the normalized address instead. (TK-10703, Cody gate.)
+    const rows = await brokerdb.pool.query(`SELECT id, address, created_at FROM listing WHERE created_at IS NOT NULL`).then(r => r.rows);
     const m = {}; for (const row of rows) m[row.id] = row.created_at;
-    res.json({ dates: m });
-  } catch (e) { res.json({ dates: {} }); }
+    res.json({ dates: m, rows: rows.map(r => ({ address: r.address, created_at: r.created_at })) });
+  } catch (e) { res.json({ dates: {}, rows: [] }); }
 });
 // TK-10703: per-listing REAL broker contact by ADDRESS. listings.json only knows the portal
 // ("Crexi") as firm, so a firm-name join yields ~0 contacts; the honest source is the broker

← 30278bc just-listed: rebuild to canonical CRCP UI (grid/list/table,  ·  back to Commercialrealestate  ·  CRCP fleet: add canonical day/night theme (crcp-theme.css/js dbe3051 →