← 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 →