[object Object]

← back to Commercialrealestate

CRCP mls: admin default 4-unit multifamily + persist-filters-on-every-change + contact columns (TK-10687)

d1b8c3d5fdc160a3bd42f28034aeffa4870e4cc0 · 2026-08-18 13:12:33 -0700 · Steve Abrams

Steve: 'using admin, onload 4-unit multifamily with date, address, broker + firm
names, phone, emails and websites' + 'persist save on every change' + 'onload load
user's saved filters'.
- Filter persistence: serialize F -> mlsFilters localStorage key (auto-rides the
  existing /^mls/ profile sync); persist() fires on EVERY render (every filter
  change) + debounced server save; hydrateFilters() restores on load + on profile
  sync. Verified: set units 4-4 -> reload -> restored, grid = 238 of 25,638.
- Admin default: first-time admin (perm=admin, no saved view) opens onto 4-unit
  Multifamily with columns date/address/city/units/price/cap/broker/brokerage/
  phone/email/website, freshest first.
- Contact columns: added Brokerage + Phone(tel:) + Email(mailto:) + Website cols;
  merge broker phone/email/website from /api/brokers/all by broker name; new
  /api/listing-dates endpoint merges listing.created_at as the 'First seen' date.
- Verified headless (real Chrome): 0 console errors; 238 4-unit MF rows populate
  date 170 / broker 232 / brokerage 223 / phone 77 / email 44 / website 93 (the
  phone/email gap fills as the DRE contact enrichment pipeline runs).

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

Files touched

Diff

commit d1b8c3d5fdc160a3bd42f28034aeffa4870e4cc0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Aug 18 13:12:33 2026 -0700

    CRCP mls: admin default 4-unit multifamily + persist-filters-on-every-change + contact columns (TK-10687)
    
    Steve: 'using admin, onload 4-unit multifamily with date, address, broker + firm
    names, phone, emails and websites' + 'persist save on every change' + 'onload load
    user's saved filters'.
    - Filter persistence: serialize F -> mlsFilters localStorage key (auto-rides the
      existing /^mls/ profile sync); persist() fires on EVERY render (every filter
      change) + debounced server save; hydrateFilters() restores on load + on profile
      sync. Verified: set units 4-4 -> reload -> restored, grid = 238 of 25,638.
    - Admin default: first-time admin (perm=admin, no saved view) opens onto 4-unit
      Multifamily with columns date/address/city/units/price/cap/broker/brokerage/
      phone/email/website, freshest first.
    - Contact columns: added Brokerage + Phone(tel:) + Email(mailto:) + Website cols;
      merge broker phone/email/website from /api/brokers/all by broker name; new
      /api/listing-dates endpoint merges listing.created_at as the 'First seen' date.
    - Verified headless (real Chrome): 0 console errors; 238 4-unit MF rows populate
      date 170 / broker 232 / brokerage 223 / phone 77 / email 44 / website 93 (the
      phone/email gap fills as the DRE contact enrichment pipeline runs).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/mls.html  | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 scripts/serve.js | 10 ++++++++
 2 files changed, 81 insertions(+), 2 deletions(-)

diff --git a/public/mls.html b/public/mls.html
index 8eb4ed7..9cfe9dd 100644
--- a/public/mls.html
+++ b/public/mls.html
@@ -262,7 +262,11 @@ const COLS=[
   {k:'sold_date',l:'Sold',t:'s'},
   {k:'first_seen',l:'First seen',t:'datetime'},
   {k:'firm',l:'Firm',t:'s'},
-  {k:'broker_agent',l:'Agent',t:'s'},
+  {k:'broker_agent',l:'Broker',t:'s'},
+  {k:'broker_firm',l:'Brokerage',t:'s'},
+  {k:'phone',l:'Phone',t:'tel',calc:r=>r.agent_phone||r.phone||null},
+  {k:'email',l:'Email',t:'email',calc:r=>r.email||null},
+  {k:'website',l:'Website',t:'web',calc:r=>r.agent_site||r.broker_url||r.firm_website||null},
   {k:'last_broker_of_record',l:'Last Broker of Record',t:'s'},
   {k:'last_firm_of_record',l:'Last Firm of Record',t:'s'},
   {k:'source_of_truth',l:'Source',t:'s'},
@@ -390,12 +394,48 @@ function rehydrateView(){
   const sd=localStorage.getItem('mlsSortDir'); if(sd!==null&&sd!=='') sortDir=(+sd<0?-1:1);
   const th=localStorage.getItem('crcp-theme2'); if(th) document.documentElement.setAttribute('data-theme', th==='dark'?'dark':'light');
   lastColSig='';                                   // force column widths/resize-handles to re-init
+  try{ hydrateFilters(); }catch(e){}                // TK-10687: apply the profile-synced saved filters too
   try{ buildRail(); }catch(e){}                    // keep the left rail's field-toggles in sync with the restored columns
   if(window.__syncSortSel) window.__syncSortSel();
   render();                                         // global table render — reflects the restored view immediately
 }
 function saveCols(){ try{localStorage.setItem('mlsCols',JSON.stringify(VISCOL));}catch(e){} }
 function saveColOrder(){ try{localStorage.setItem('mlsColOrder',JSON.stringify(COLORDER));}catch(e){} }
+// ── Filter persistence (TK-10687, Steve: "persist save on every change" + "onload load saved filters") ──
+// Serialize the live filter state F (Sets + scalars) into the `mlsFilters` localStorage key so it
+// survives reloads AND rides the existing profile sync — collect()/save() picks up any /^mls/ key, so a
+// signed-in user's filters follow them across devices. hydrateFilters() restores them on load.
+const _FSETS=['types','cities','status','source','firms'];
+const _FSCAL=['pmin','pmax','umin','umax','ymin','ymax','recentDays'];
+const _FBOOL=['unwarrantable','nonqm','shortlist','justsold','justlisted','inescrow','fellout','pricecut'];
+function serializeFilters(){ const o={}; _FSETS.forEach(k=>{ if(F[k]&&F[k].size) o[k]=[...F[k]]; }); _FSCAL.forEach(k=>{ if(F[k]!=null) o[k]=F[k]; }); _FBOOL.forEach(k=>{ if(F[k]) o[k]=1; }); if(q) o.q=q; return o; }
+function saveFilters(){ try{ const o=serializeFilters(); if(Object.keys(o).length) localStorage.setItem('mlsFilters',JSON.stringify(o)); else localStorage.removeItem('mlsFilters'); }catch(e){} }
+function syncFilterInputs(){ try{ const set=(id,v)=>{ const el=document.querySelector('#'+id); if(el) el.value=(v==null?'':v); };
+  set('pMin',F.pmin);set('pMax',F.pmax);set('uMin',F.umin);set('uMax',F.umax);set('yMin',F.ymin);set('yMax',F.ymax);
+  if(typeof STAGE==='object'&&STAGE){ STAGE.pmin=F.pmin;STAGE.pmax=F.pmax;STAGE.umin=F.umin;STAGE.umax=F.umax;STAGE.ymin=F.ymin;STAGE.ymax=F.ymax; } }catch(e){} }
+function hydrateFilters(){ let o; try{ o=JSON.parse(localStorage.getItem('mlsFilters')||'null'); }catch(e){ o=null; } if(!o||typeof o!=='object') return false;
+  _FSETS.forEach(k=>{ F[k]=new Set(Array.isArray(o[k])?o[k]:[]); });
+  _FSCAL.forEach(k=>{ if(o[k]!=null) F[k]=o[k]; });
+  _FBOOL.forEach(k=>{ F[k]=!!o[k]; });
+  if('q' in o){ q=o.q||''; const qi=document.querySelector('#q'); if(qi) qi.value=q; }
+  syncFilterInputs(); return true; }
+// Persist-on-every-change: render() calls persist() once the app is READY (so the initial boot that
+// applies the saved/default view doesn't immediately clobber it). Writes filters locally every change
+// + debounces the server profile sync (window.__mlsServerSave, exposed by the accounts layer).
+let _persistReady=false, _persistT=null;
+function persist(){ if(!_persistReady) return; saveFilters(); if(window.__mlsServerSave){ clearTimeout(_persistT); _persistT=setTimeout(()=>{ try{ window.__mlsServerSave(); }catch(e){} },700); } }
+// First-time ADMIN default (Steve): open onto 4-unit multifamily with date/address/broker/firm/phone/
+// email/website. Only when the user has NO saved view yet — a returning user's saved filters win.
+async function maybeAdminDefault(){
+  if(localStorage.getItem('mlsFilters')) return false;      // already have a saved/last view → respect it
+  let me=null; try{ me=await (await fetch('/api/me',{headers:{'Content-Type':'application/json'}})).json(); }catch(e){}
+  if(!me || me.perm!=='admin') return false;
+  F.types=new Set(['Multifamily']); F.umin=4; F.umax=4;
+  const SHOW=['first_seen','address','city','units','price','cap_rate','broker_agent','broker_firm','phone','email','website'];
+  VISCOL={}; COLS.forEach(c=>{ VISCOL[c.k]=SHOW.includes(c.k); });
+  sortKey='first_seen'; sortDir=-1;                         // freshest 4-unit MF first
+  try{ saveCols(); }catch(e){} saveFilters(); syncFilterInputs();
+  try{ buildRail(); }catch(e){} return true; }
 // When the visible column SET or ORDER changes, hand the table back to col-resize.js
 // so it rebuilds its <colgroup> + drag handles for the current columns.
 function reinitResize(){
@@ -507,6 +547,9 @@ function cellHTML(r,c){
   if(c.t==='n') return num(v);
   if(c.t==='datetime'){ if(v==null||v==='') return '—'; const dt=new Date(v); if(isNaN(dt)) return '—'; return `<span title="${esc(String(v))}">🕓 ${dt.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})}</span>`; }
   if(c.t==='status'){ const a=String(v||'').startsWith('Active'); return `<span class="pill ${a?'active':'other'}">${esc(v||'—')}</span>`; }
+  if(c.t==='tel'){ return v?`<a href="tel:${esc(String(v).replace(/[^0-9+]/g,''))}" title="call">📞 ${esc(v)}</a>`:'<span class="mut" title="phone enrichment pending">—</span>'; }
+  if(c.t==='email'){ return v?`<a href="mailto:${esc(v)}" title="email">✉ ${esc(v)}</a>`:'<span class="mut" title="email enrichment pending">—</span>'; }
+  if(c.t==='web'){ return v?`<a href="${esc(/^https?:/.test(v)?v:'https://'+v)}" target="_blank" rel="noopener noreferrer" title="broker / firm website">🌐 site ↗</a>`:'—'; }
   if(c.t==='link'){ const u=brokerListing(r); const ab=agentBits(r);
     if(u.mode==='listing') return `<a href="${esc(u.href)}" target="_blank" rel="noopener noreferrer" title="${esc(u.title)}">🔗 ${esc(u.label)}</a>`+ab;
     // mode==='text': firm site (if non-agg) + always a find link, then agent phone/own-site
@@ -546,6 +589,7 @@ function filtered(){
 }
 function render(){
   updateShortBtn();
+  persist();   // TK-10687: persist filter state on every change (local + debounced profile sync)
   // Amazon-style: land POPULATED. Show the whole book on open (paginated to 200 rows in the DOM),
   // and let the left rail NARROW it. The only empty case is the split-second before data arrives.
   if(!DATA.length){
@@ -682,6 +726,7 @@ if($('#csv2')) $('#csv2').addEventListener('click',exportCSV);
   const inp='background:var(--card);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px;width:120px';
   let ME=null, SAVED_AT=null;
   async function save(silent){ if(!ME||!ME.email) return; const s=collect(); if(!Object.keys(s).length) return; try{ const r=await api('/api/settings',{method:'POST',body:JSON.stringify({settings:s})}); SAVED_AT=r.savedAt; if(!silent) render(); }catch(e){} }
+  window.__mlsServerSave=()=>save(true);   // TK-10687: let the table's persist() debounce-sync to the profile
   // save-on-exit: NEVER write an empty blob (would wipe the account's saved view), and only after the
   // initial load/sync has completed this session, so a fast navigate can't clobber a not-yet-loaded view.
   function saveBeacon(){ if(!ME||!ME.email) return; if(!sessionStorage.getItem('mls_settings_synced')) return; const s=collect(); if(!Object.keys(s).length) return; try{ navigator.sendBeacon('/api/settings', new Blob([JSON.stringify({settings:s})],{type:'application/json'})); }catch(e){} }
@@ -871,13 +916,37 @@ fetch('/data/ranked.json').then(r=>r.json()).then(async d=>{ DATA=(d.ranked||d)|
       r.agent_site_status=e.status||null;   // found | no_url
     });
   }catch(e){}
+  // Broker/firm contact (TK-10687, Steve: 4-unit MF must show broker+firm name+phone+email+website).
+  // Merge the enriched broker roster (/api/brokers/all: name→phone/email/website) onto listings by the
+  // listing broker's name. Fills email + any phone/site the agent-sites pass missed. $0 (local join).
+  try{ const ba=await (await fetch('/api/brokers/all')).json(); const BK={};
+    const nrm=s=>String(s||'').toLowerCase().replace(/[^a-z0-9]+/g,' ').trim();
+    (ba.brokers||ba.rows||[]).forEach(b=>{ const k=nrm(b.name); if(!k) return; const e=BK[k]||(BK[k]={});
+      if(b.email&&!e.email) e.email=b.email; if(b.phone&&!e.phone) e.phone=b.phone;
+      if(b.website&&!e.website) e.website=b.website; if(b.office_addr&&!e.office_addr) e.office_addr=b.office_addr; });
+    DATA.forEach(r=>{ const e=BK[nrm(r.broker_agent)]; if(!e) return;
+      if(e.email&&!r.email) r.email=e.email;
+      if(e.phone&&!r.agent_phone) r.agent_phone=e.phone;
+      if(e.website&&!r.agent_site) r.agent_site=e.website; });
+  }catch(e){}
+  // Listing capture date (TK-10687): ranked.json rows have no date; merge listing.created_at by id so
+  // the multifamily view shows a real "First seen" date. Rows already carrying first_seen keep it.
+  try{ const ld=await (await fetch('/api/listing-dates')).json(); const DT=(ld&&ld.dates)||{};
+    DATA.forEach(r=>{ if(!r.first_seen && r.id && DT[r.id]) r.first_seen=DT[r.id]; });
+  }catch(e){}
   DATA.forEach(r=>{ r._hay=buildHay(r); });   // (re)build the cached search haystack now that every enrichment field is attached
   // Separate the RENDER failure mode from the LOAD failure mode (DTD 5/5 + contrarian, 2026-08-18).
   // Previously one outer .catch relabeled ANY error here — a render/parse TypeError included — as
   // "failed to load /data/ranked.json", which masked a real render-time bug during debugging. The
   // data is already in hand at this point, so a throw from autoCols/buildRail/render is a RENDER
   // fault, not a load fault; report it as such and log the real stack.
-  try { autoCols(); buildRail(); render(); }
+  try {
+    autoCols(); buildRail();
+    hydrateFilters();                 // TK-10687: restore this user's saved filters (local / returning)
+    await maybeAdminDefault();        // first-time admin with no saved view → 4-unit multifamily default
+    _persistReady=true;               // load complete → safe to persist changes from here on
+    render();
+  }
   catch(e){ console.error('CRCP: data loaded but render failed —', e); $('#count').textContent='data loaded but failed to render — see console'; } })
   .catch(e=>{ console.error('CRCP: /data/ranked.json load/parse failed —', e); $('#count').textContent='failed to load /data/ranked.json'; });
 
diff --git a/scripts/serve.js b/scripts/serve.js
index 38b2371..9a00071 100644
--- a/scripts/serve.js
+++ b/scripts/serve.js
@@ -166,6 +166,16 @@ const AGENT_SITES = path.join(ROOT, 'data', 'agent-sites.json');
 const readAgentSites = () => { try { return JSON.parse(fs.readFileSync(AGENT_SITES, 'utf8')); } catch (_) { return {}; } };
 const writeAgentSites = (m) => { const t = AGENT_SITES + '.tmp'; fs.writeFileSync(t, JSON.stringify(m, null, 2)); fs.renameSync(t, AGENT_SITES); };
 app.get('/api/agent-sites', (req, res) => res.json({ sites: readAgentSites() }));
+// TK-10687: per-listing capture date (first_seen) for the multifamily rows — ranked.json carries no
+// 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);
+    const m = {}; for (const row of rows) m[row.id] = row.created_at;
+    res.json({ dates: m });
+  } catch (e) { res.json({ dates: {} }); }
+});
 // On-demand: the $0 lazy path a deal's broker cell fires when it has no agent site yet.
 app.post('/api/agent/discover', async (req, res) => {
   try {

← 4c32dfd afternoon CRE update 2026-08-18  ·  back to Commercialrealestate  ·  CRCP mls: natural-language chat bar across the top (TK-10698 938bfff →