← 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
M public/fha-leads.htmlM public/just-listed.htmlM scripts/serve.js
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 →