[object Object]

← back to Commercialrealestate

CRCP mls: natural-language chat bar across the top (TK-10698)

938bfffb80f0115d6555073f89cf7be5acec8474 · 2026-08-18 14:55:05 -0700 · Steve Abrams

Steve: 'add a chat across the top to access all the filters and data, so i can
just ask for 4 units in los angeles and it comes up.' Full-width chat bar under
the header with a $0 deterministic NL parser: units ('4 units', '2-4 units',
'under 5 units', duplex/fourplex), city (matched against real data city names),
type (multifamily/office/retail/mixed/NNN), price ('under $2M', '$1M-$3M'), cap
('6% cap'), year ('built after 2000'). Each query replaces the chat-controlled
filters, syncs the left rail + numeric inputs, persists via mlsFilters, and shows
what it parsed. Added F.capmin + a filtered() cap check. Verified live: '4 units
in los angeles' -> 46 of 25,638, all 4-unit LA multifamily, 0 console errors.

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

Files touched

Diff

commit 938bfffb80f0115d6555073f89cf7be5acec8474
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Aug 18 14:55:05 2026 -0700

    CRCP mls: natural-language chat bar across the top (TK-10698)
    
    Steve: 'add a chat across the top to access all the filters and data, so i can
    just ask for 4 units in los angeles and it comes up.' Full-width chat bar under
    the header with a $0 deterministic NL parser: units ('4 units', '2-4 units',
    'under 5 units', duplex/fourplex), city (matched against real data city names),
    type (multifamily/office/retail/mixed/NNN), price ('under $2M', '$1M-$3M'), cap
    ('6% cap'), year ('built after 2000'). Each query replaces the chat-controlled
    filters, syncs the left rail + numeric inputs, persists via mlsFilters, and shows
    what it parsed. Added F.capmin + a filtered() cap check. Verified live: '4 units
    in los angeles' -> 46 of 25,638, all 4-unit LA multifamily, 0 console errors.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/mls.html | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 64 insertions(+), 2 deletions(-)

diff --git a/public/mls.html b/public/mls.html
index 9cfe9dd..b48d20d 100644
--- a/public/mls.html
+++ b/public/mls.html
@@ -170,6 +170,16 @@
   <button class="csvbtn" id="csv" title="Export current rows to CSV">⬇ CSV</button>
   <span class="count" id="count">…</span>
 </header>
+<!-- TK-10698: natural-language chat bar — "4 units in los angeles" → sets filters + re-renders -->
+<div id="chatbar" style="display:flex;align-items:center;gap:9px;padding:9px 18px;border-bottom:1px solid var(--line);background:var(--headbg,var(--card));position:sticky;top:0;z-index:39;">
+  <span style="font-size:16px" title="Ask for listings in plain English">💬</span>
+  <input id="chatq" type="text" autocomplete="off" spellcheck="false"
+    placeholder="Ask for what you want — e.g.  “4 units in Los Angeles”  ·  “multifamily under $2M in Long Beach”  ·  “retail over 6% cap”  ·  “built after 2000”  (Enter)"
+    style="flex:1;min-width:0;background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:9px;padding:9px 13px;font-size:14px;">
+  <button id="chatgo" class="gobtn" title="Apply this request">Ask ▸</button>
+  <button id="chatclear" class="minibtn" title="Clear the chat filters">Clear</button>
+  <span id="chatmsg" style="font-size:12px;color:var(--mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:34%"></span>
+</div>
 <div class="shell">
 <aside class="rail">
   <div class="rsec" id="fieldsSec"><h4>Fields / columns — show / hide · reorder</h4>
@@ -276,7 +286,7 @@ const COLS=[
 ];
 let DATA=[], view='table', sortKey='dom', sortDir=1, q='', lastColSig='';  // default landing: freshest listings first (days-on-market asc); localStorage overrides for returning users
 const _rd=(function(){ const v=parseInt(localStorage.getItem('mlsRecentDays'),10); return (v>0&&v<=365)?v:7; })();
-const F={types:new Set(),cities:new Set(),status:new Set(),source:new Set(),firms:new Set(),pmin:null,pmax:null,umin:null,umax:null,ymin:null,ymax:null,unwarrantable:false,nonqm:false,shortlist:false,justsold:false,justlisted:false,inescrow:false,fellout:false,pricecut:false,recentDays:_rd};
+const F={types:new Set(),cities:new Set(),status:new Set(),source:new Set(),firms:new Set(),pmin:null,pmax:null,umin:null,umax:null,ymin:null,ymax:null,capmin:null,unwarrantable:false,nonqm:false,shortlist:false,justsold:false,justlisted:false,inescrow:false,fellout:false,pricecut:false,recentDays:_rd};
 const daysSince=d=>{ if(!d) return Infinity; const t=Date.parse(d); return isNaN(t)?Infinity:(Date.now()-t)/86400000; };
 // Real listing age: Redfin DAYS ON MARKET when present (gap-immune), else our first-seen date.
 const listAge=r=>{ if(r.dom!=null&&isFinite(+r.dom)) return +r.dom; return daysSince(r.first_seen); };
@@ -406,7 +416,7 @@ function saveColOrder(){ try{localStorage.setItem('mlsColOrder',JSON.stringify(C
 // 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 _FSCAL=['pmin','pmax','umin','umax','ymin','ymax','capmin','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){} }
@@ -497,6 +507,7 @@ function passFacets(r){
   if(F.umax!=null && !(r.units!=null && +r.units<=F.umax)) return false;
   if(F.ymin!=null && !(r.year_built && +r.year_built>=F.ymin)) return false;
   if(F.ymax!=null && !(r.year_built && +r.year_built<=F.ymax)) return false;
+  if(F.capmin!=null && !(r.cap_rate!=null && +r.cap_rate>=F.capmin)) return false;   // TK-10698 chat cap filter
   if(F.unwarrantable && !(r.type==='Condo' && r.warrantable_status && r.warrantable_status!=='fha_approved')) return false;
   if(F.nonqm && !isNonQM(r)) return false;
   // Just Listed = still on the market (not sold/withdrawn) & listed within N days (real DAYS ON MARKET).
@@ -813,6 +824,57 @@ const NUMFIELDS=[['pMin','pmin'],['pMax','pmax'],['uMin','umin'],['uMax','umax']
 function stageDirty(){ return NUMFIELDS.some(([,k])=>STAGE[k]!==F[k]); }
 function markStaged(){ const f=$('#stagedFlag'); if(f) f.style.display=stageDirty()?'inline':'none'; }
 function applyStaged(){ NUMFIELDS.forEach(([,k])=>{ F[k]=STAGE[k]; }); markStaged(); render(); }
+
+// ── TK-10698: natural-language chat → filters ("4 units in los angeles") ──────────────────────
+const CHAT_TYPES=[['multifamily','Multifamily'],['multi-family','Multifamily'],['multi family','Multifamily'],['apartment','Multifamily'],['office','Office'],['retail','Retail'],['mixed-use','Mixed-use'],['mixed use','Mixed-use'],['net lease','Retail/NNN'],['net-lease','Retail/NNN'],['nnn','Retail/NNN']];
+function chatMoney(s){ s=String(s).toLowerCase().replace(/[,\s$]/g,''); let n=parseFloat(s); if(isNaN(n))return null; if(/m/.test(s))n*=1e6; else if(/k/.test(s))n*=1e3; return Math.round(n); }
+function chatCities(){ return [...new Set(DATA.map(r=>(r.city||'').trim()).filter(Boolean))].sort((a,b)=>b.length-a.length); }
+function applyChat(raw){
+  const msg=document.getElementById('chatmsg');
+  const s=' '+String(raw||'').toLowerCase().replace(/\s+/g,' ').trim()+' ';
+  if(!s.trim()){ return; }
+  // whole-query reset
+  if(/^\s*(clear|reset|all|everything|start over|show all)\s*$/.test(s)){ chatClearFilters(); if(msg)msg.textContent='Cleared filters'; return; }
+  // Each chat query REPLACES the fields chat controls (fresh search); leave shortlist/quick chips alone.
+  F.types.clear(); F.cities.clear(); F.umin=F.umax=F.pmin=F.pmax=F.ymin=F.ymax=F.capmin=null;
+  const got=[]; let m;
+  // UNITS — "2-4 units", "under 5 units", "over 3 units", "4 units", "4 unit", "fourplex/duplex/triplex"
+  const WORDU={duplex:2,triplex:3,fourplex:4,fiveplex:5};
+  for(const w in WORDU){ if(s.includes(' '+w)){ F.umin=F.umax=WORDU[w]; got.push(WORDU[w]+' units'); } }
+  if(m=s.match(/(\d+)\s*(?:-|to)\s*(\d+)\s*unit/)){ F.umin=+m[1]; F.umax=+m[2]; got.push(m[1]+'–'+m[2]+' units'); }
+  else if(m=s.match(/(?:under|below|less than|up to|max(?:imum)?|<)\s*(\d+)\s*unit/)){ F.umax=+m[1]; got.push('≤'+m[1]+' units'); }
+  else if(m=s.match(/(?:over|above|more than|at least|min(?:imum)?|\+|>)\s*(\d+)\s*unit/)){ F.umin=+m[1]; got.push(m[1]+'+ units'); }
+  else if(m=s.match(/(\d+)\s*\+\s*unit/)){ F.umin=+m[1]; got.push(m[1]+'+ units'); }
+  else if(m=s.match(/(\d+)\s*unit/)){ F.umin=F.umax=+m[1]; got.push(m[1]+' units'); }
+  // TYPE
+  for(const [k,v] of CHAT_TYPES){ if(s.includes(' '+k)){ F.types.add(v); if(!got.includes(v))got.push(v); } }
+  // CITY (known city names from the data)
+  for(const c of chatCities()){ if(s.includes(' '+c.toLowerCase()+' ') || s.includes(' in '+c.toLowerCase())){ F.cities.add(c); got.push(c); } }
+  // PRICE — "$1m-$3m", "under $2m", "over $500k"
+  if(m=s.match(/\$?\s*([\d.,]+\s*[mk]?)\s*(?:-|to)\s*\$?\s*([\d.,]+\s*[mk]?)/) && /\$|[mk]\b/.test(m[0])){ F.pmin=chatMoney(m[1]); F.pmax=chatMoney(m[2]); got.push('$'+m[1].trim()+'–$'+m[2].trim()); }
+  else if(m=s.match(/(?:under|below|less than|up to|<)\s*\$?\s*([\d.,]+\s*[mk]?)/) && /\$|[mk]\b|\d{4,}/.test(m[0])){ F.pmax=chatMoney(m[1]); got.push('< $'+m[1].trim()); }
+  else if(m=s.match(/(?:over|above|more than|at least|>)\s*\$?\s*([\d.,]+\s*[mk]?)/) && /\$|[mk]\b|\d{4,}/.test(m[0])){ F.pmin=chatMoney(m[1]); got.push('> $'+m[1].trim()); }
+  // CAP RATE — "6% cap", "cap over 6", "6 cap"
+  if(m=s.match(/(\d+(?:\.\d+)?)\s*%?\s*cap/) || (m=s.match(/cap\s*(?:rate)?\s*(?:over|above|min|>|of)?\s*(\d+(?:\.\d+)?)/))){ F.capmin=+m[1]; got.push(m[1]+'%+ cap'); }
+  // YEAR
+  if(m=s.match(/(?:built\s*after|newer than|after|since)\s*(\d{4})/)){ F.ymin=+m[1]; got.push('after '+m[1]); }
+  if(m=s.match(/(?:built\s*before|older than|before)\s*(\d{4})/)){ F.ymax=+m[1]; got.push('before '+m[1]); }
+  try{ syncFilterInputs(); }catch(e){}
+  try{ buildRail(); }catch(e){}      // keep the left rail's type/city chips in sync with the chat
+  try{ saveFilters(); }catch(e){}
+  render();
+  if(msg) msg.textContent = got.length ? ('Showing: '+got.join(' · ')) : ('Couldn’t parse — try “4 units in Los Angeles”');
+}
+function chatClearFilters(){
+  F.types.clear(); F.cities.clear(); F.status.clear(); F.source.clear(); F.firms.clear();
+  F.umin=F.umax=F.pmin=F.pmax=F.ymin=F.ymax=F.capmin=null; F.unwarrantable=F.nonqm=false;
+  try{ syncFilterInputs(); buildRail(); saveFilters(); }catch(e){} render();
+}
+(function(){ const q=document.getElementById('chatq'), go=document.getElementById('chatgo'), cl=document.getElementById('chatclear');
+  if(q){ q.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); applyChat(q.value); } }); }
+  if(go) go.addEventListener('click',()=>applyChat(q.value));
+  if(cl) cl.addEventListener('click',()=>{ q.value=''; chatClearFilters(); const m=document.getElementById('chatmsg'); if(m)m.textContent='Cleared filters'; });
+})();
 NUMFIELDS.forEach(([id,k])=>{ const el=$('#'+id); if(!el) return;
   el.addEventListener('input',()=>{ STAGE[k]=el.value?+el.value:null; markStaged(); });
   el.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); applyStaged(); } });

← d1b8c3d CRCP mls: admin default 4-unit multifamily + persist-filters  ·  back to Commercialrealestate  ·  CRCP mls chat: example chips + unwarrantable/non-QM/condo te 5d89be8 →