← back to Commercialrealestate

public/mls.html

1099 lines

<!doctype html>
<html lang="en">
<head>
<!-- Credential-safe fetch (2026-08-18). Opening this page as http://admin:PASS@host/mls.html leaves
     the embedded credentials in document.baseURI (history.replaceState clears location.href but NOT
     baseURI). Every relative fetch('/data/…') resolves against that credentialed base and throws
     "Request cannot be constructed from a URL that includes credentials" — the page renders but the
     grid never fills ("failed to load"). Fix: resolve fetches against location.origin (which never
     carries userinfo) and strip creds from any absolute URL, so the request is always constructible.
     Same-origin so the Basic-auth session cookie (set on this navigation) authenticates it. Must run
     before any fetch(). Also tidy the visible address bar. -->
<script>/* credential-safe fetch (2026-08-18): resolve EVERY relative fetch (with or without a leading slash) against a credential-free base so creds embedded in the page URL (http://user:pass@host/...) don't poison document.baseURI and throw "Request cannot be constructed from a URL that includes credentials". */(function(){var of=window.fetch,O=location.origin;window.fetch=function(u,o){try{if(typeof u==='string'){if(/^[a-z][a-z0-9+.-]*:/i.test(u)){var x=new URL(u);if(x.username||x.password){x.username='';x.password='';u=x.href;}}else{u=new URL(u,O+location.pathname+location.search).href;}}else if(u&&typeof u.url==='string'){var y=new URL(u.url,O+location.pathname+location.search);if(y.username||y.password){y.username='';y.password='';u=new Request(y.href,u);}}}catch(e){}return of.call(this,u,o);};try{if(location.href.indexOf('@')>=0){history.replaceState(null,'',location.pathname+location.search+location.hash);}}catch(e){}})();</script>
<script>(function(){try{var t=localStorage.getItem('crcp-theme');document.documentElement.setAttribute('data-theme',t==='dark'?'dark':'light');}catch(e){document.documentElement.setAttribute('data-theme','light');}})();</script>
<!-- Self-heal: the Nav Agent grid controls can persist a "removed"/"collapsed" state that hides the
     WHOLE records grid with no obvious way back (this is how the listings went blank). The main data
     grid should never stay removed/collapsed across loads, so clear that state before nav-agent runs. -->
<script>(function(){try{for(var i=localStorage.length-1;i>=0;i--){var k=localStorage.key(i);if(k&&k.indexOf('navagent:')===0){var v=localStorage.getItem(k);if(v&&v.indexOf('"removed":true')>=0){localStorage.removeItem(k);}}}}catch(e){}})();</script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%230e1116'/%3E%3Ctext x='8' y='12' font-size='10' text-anchor='middle' fill='%233fb950'%3EMLS%3C/text%3E%3C/svg%3E">
<title>LA County Listings — Residential + CRE</title>
<style>
  :root, :root[data-theme="dark"]{
    --bg:#0d1117; --bg2:#171d26; --card:#1c232e; --line:#33404f; --line2:#3f4c5c;
    --ink:#f2f6fb; --mut:#b3c0cf; --headbg:rgba(13,17,23,.98); --stripe:rgba(255,255,255,.025);
    --hover:rgba(88,166,255,.10);
    --acc:#4bd268; --warn:#d29922; --bad:#f85149; --blue:#58a6ff;
  }
  :root[data-theme="light"]{
    --bg:#f4f6f9; --bg2:#ffffff; --card:#ffffff; --line:#d5dbe2; --line2:#c2cad4;
    --ink:#12171e; --mut:#525f6e; --headbg:rgba(255,255,255,.98); --stripe:rgba(20,30,45,.028);
    --hover:rgba(9,105,218,.08);
    --acc:#1a7f37; --warn:#9a6700; --bad:#cf222e; --blue:#0969da; --muted:#525f6e;
  }
  *{box-sizing:border-box;} body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;}
  header{display:flex;align-items:center;gap:12px;padding:11px 18px;border-bottom:1px solid var(--line);position:sticky;top:0;z-index:40;background:var(--headbg);backdrop-filter:blur(8px);flex-wrap:wrap;}
  header h1{font-size:16px;margin:0;} header h1 b{color:var(--acc);}
  header .nav{display:flex;gap:12px;margin-left:6px;} header .nav a{color:var(--blue);text-decoration:none;font-size:13px;} header .nav a:hover{color:var(--warn);}
  .spacer{flex:1;}
  .search{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:9px;padding:8px 12px;font-size:14px;min-width:220px;}
  .toggle{display:flex;border:1px solid var(--line);border-radius:9px;overflow:hidden;}
  .toggle button{background:var(--card);border:0;color:var(--mut);padding:8px 13px;font-size:13px;cursor:pointer;}
  .toggle button.on{background:var(--blue);color:var(--bg);font-weight:700;}
  .csvbtn{background:var(--card);border:1px solid var(--line);color:var(--acc);border-radius:9px;padding:8px 12px;font-size:13px;cursor:pointer;} .csvbtn:hover{border-color:var(--acc);}
  .count{color:var(--mut);font-size:12px;}
  .wrap{padding:20px 12px 60px;}
  /* TABLE (spreadsheet) */
  .tblwrap{overflow:auto;max-height:calc(100vh - 130px);border:1px solid var(--line);border-radius:10px;}
  table.mls{border-collapse:separate;border-spacing:0;width:100%;font-size:12.5px;white-space:nowrap;}
  /* records table always fills the panel: col-resize.js pins the table to the sum of its column
     widths (inline width), which overflows/scrolls and leaves the divider-drag doing nothing to the
     columns. Force 100% so table-layout:fixed stretches the columns to fill — and the records expand
     wider as you drag the divider right. Column drag-resize still works (adjusts proportions). */
  #tableView table.mls{width:var(--rec-w,100%) !important;min-width:100%;}
  #tableView table.mls colgroup[data-cr] col{width:auto !important;}
  #tableView table.mls td,#tableView table.mls th{overflow:hidden;text-overflow:ellipsis;}
  /* right-edge grip: drag to widen the records past the screen edge (columns get wider, table scrolls) */
  .recgrip{flex:0 0 7px;align-self:stretch;position:sticky;top:53px;height:calc(100vh - 53px);background:var(--line);cursor:ew-resize;z-index:6;transition:background .12s;}
  .recgrip:hover,.recgrip.dragging{background:var(--acc);}
  @media(max-width:820px){ .recgrip{display:none;} }
  table.mls thead th{position:sticky;top:0;background:var(--bg2);color:var(--mut);text-transform:uppercase;letter-spacing:.3px;font-size:10.5px;padding:8px 10px;border-bottom:1px solid var(--line);cursor:pointer;user-select:none;z-index:2;}
  table.mls thead th:hover{color:var(--ink);}
  table.mls thead th.asc::after{content:' ▲';color:var(--acc);} table.mls thead th.desc::after{content:' ▼';color:var(--acc);}
  /* draggable column reorder (Steve rule 2026-07-31: move columns around) */
  table.mls thead th.dragcol{cursor:grab;}
  table.mls thead th.dragging{opacity:.45;}
  table.mls thead th.dropL{box-shadow:inset 3px 0 0 var(--acc);}
  table.mls thead th.dropR{box-shadow:inset -3px 0 0 var(--acc);}
  table.mls tbody td{padding:7px 10px;border-bottom:1px solid var(--line);}
  table.mls tbody tr:hover td{background:rgba(88,166,255,.07);}
  table.mls td.n{text-align:right;font-variant-numeric:tabular-nums;}
  table.mls a{color:var(--blue);text-decoration:none;} table.mls a:hover{text-decoration:underline;}
  .pill{font-size:10px;padding:1px 6px;border-radius:8px;font-weight:600;}
  .pill.active{background:rgba(63,185,80,.16);color:var(--acc);} .pill.other{background:rgba(210,153,34,.16);color:var(--warn);}
  .mut{color:var(--mut);}
  /* ColumnManager ships a hardcoded DARK palette; remap it to the page theme so its
     text (search box, "More columns"/"Shown by default" headers, All/None/Reset, hint)
     is readable on the light theme (dark ink on white) instead of near-white on white. */
  #fFields .cm{ --cm-ink:var(--ink); --cm-mut:var(--mut); --cm-card:var(--bg2); --cm-line:var(--line); --cm-gold:var(--ink); --cm-acc:var(--acc); }
  #fFields .cm-grp{ color:var(--ink); opacity:1; }
  #fFields .cm-search::placeholder{ color:var(--mut); opacity:1; }
  /* GRID */
  .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--card-min,260px),1fr));gap:20px;}
  #sortsel{background:var(--card);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:5px 8px;font-size:12px}
  #denswrap{display:none;align-items:center;gap:6px;font-size:13px;color:var(--mut)}
  #denswrap input[type=range]{width:92px;accent-color:var(--acc)}
  .gc{background:var(--card);border:1px solid var(--line);border-radius:11px;padding:18px 20px;}
  .gc .addr{font-weight:600;} .gc .sub{color:var(--mut);font-size:12px;margin-bottom:6px;}
  .gc .row{display:flex;justify-content:space-between;font-size:12.5px;padding:2px 0;border-top:1px solid var(--line);}
  .gc .row .k{color:var(--mut);} .gc .row .v{font-variant-numeric:tabular-nums;}
  .hide{display:none;}
  /* property panel docks to the RIGHT as a full-height, resizable side panel (no longer a floating card) */
  #ov{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:60;align-items:stretch;justify-content:flex-end;padding:0;overflow:hidden;}
  #ov.on{display:flex;}
  #modal{width:var(--modal-w,min(760px,96vw));max-width:96vw;height:100vh;overflow-y:auto;background:var(--card);border-left:1px solid var(--line);border-radius:0;padding:20px 26px 40px 22px;position:relative;box-shadow:-12px 0 40px rgba(0,0,0,.4);}
  /* drag the LEFT edge of the panel to widen it (savable); double-click resets */
  #modalRsz{position:absolute;top:0;left:0;bottom:0;width:11px;cursor:ew-resize;z-index:3;}
  #modalRsz:hover,#modalRsz.on{background:color-mix(in srgb,var(--acc) 45%,transparent);}
  #modal h3{margin:0 0 2px;font-size:19px;} .ph-sub{color:var(--warn);font-size:13px;}
  .ph-x{position:absolute;top:12px;right:14px;background:none;border:0;color:var(--mut);font-size:22px;cursor:pointer;}
  .ph-ci{display:flex;gap:14px;flex-wrap:wrap;margin:8px 0;font-size:13.5px;}
  .ph-sec{margin-top:14px;} .ph-sec h4{margin:0 0 6px;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);}
  .ph-item{display:flex;justify-content:space-between;gap:10px;padding:4px 0;border-bottom:1px solid var(--line);font-size:13px;} .ph-mut{color:var(--mut);font-size:12.5px;}
  /* ---- Amazon-style left rail ---- */
  .shell{display:flex;align-items:flex-start;}
  .rail{flex:0 0 var(--rail-w,260px);width:var(--rail-w,260px);min-width:0;background:var(--bg2);border-right:1px solid var(--line);align-self:stretch;position:sticky;top:53px;height:calc(100vh - 53px);overflow-y:auto;overflow-x:hidden;padding:0 0 40px;}
  /* draggable divider between the filter rail and the records — drag to resize, double-click to collapse */
  .raildiv{flex:0 0 7px;align-self:stretch;position:sticky;top:53px;height:calc(100vh - 53px);background:var(--line);cursor:col-resize;z-index:6;transition:background .12s;}
  .raildiv:hover,.raildiv.dragging{background:var(--acc);}
  body.raildrag{cursor:col-resize;user-select:none;}
  @media(max-width:820px){ .raildiv{display:none;} }
  .rail .rsec{padding:13px 16px;border-bottom:1px solid var(--line);}
  .rail h4{margin:0 0 9px;font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--mut);}
  .chips{display:flex;flex-wrap:wrap;gap:6px;}
  .chip{background:var(--card);color:var(--mut);border:1px solid var(--line);border-radius:16px;padding:4px 10px;font-size:12px;cursor:pointer;user-select:none;}
  .chip:hover{color:var(--ink);} .chip.active{border-color:var(--blue);color:var(--blue);background:rgba(88,166,255,.08);}
  .chip .ct{color:var(--mut);font-size:10px;margin-left:4px;}
  .navchip{display:inline-flex;align-items:center;gap:5px;background:rgba(210,153,34,.10);border:1px solid rgba(210,153,34,.4);color:var(--warn);border-radius:16px;padding:5px 11px;font-size:12px;cursor:pointer;text-decoration:none;font-weight:600;}
  .row2{display:flex;gap:8px;} .fld{flex:1;} .fld label{display:block;font-size:10px;color:var(--mut);margin-bottom:3px;}
  .rail input[type=number]{width:100%;background:var(--card);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 9px;font-size:13px;}
  .fieldtoggles{display:flex;flex-direction:column;gap:1px;max-height:320px;overflow-y:auto;}
  .ftog{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--ink);cursor:pointer;padding:3px 4px;border-radius:6px;} .ftog:hover{background:rgba(88,166,255,.06);} .ftog input{accent-color:var(--blue);}
  .ftog .flbl{flex:1;}
  .ftog .fdrag{cursor:grab;color:var(--mut);font-size:12px;opacity:.5;user-select:none;line-height:1;} .ftog:hover .fdrag{opacity:1;}
  .ftog.fdragging{opacity:.45;}
  .ftog.fdropT{box-shadow:inset 0 3px 0 var(--acc);} .ftog.fdropB{box-shadow:inset 0 -3px 0 var(--acc);}
  .minibtn{background:var(--card);color:var(--mut);border:1px solid var(--line);border-radius:7px;padding:4px 9px;font-size:11px;cursor:pointer;margin-right:5px;} .minibtn:hover{border-color:var(--blue);color:var(--ink);}
  .gobtn{background:var(--acc);color:#04110a;border:none;border-radius:7px;padding:7px 18px;font-size:12px;font-weight:700;cursor:pointer;letter-spacing:.3px;} .gobtn:hover{filter:brightness(1.08);} .gobtn:active{transform:translateY(1px);}
  .wrap{flex:1 1 auto;min-width:0;}
  /* results toolbar (CSV download + hint) */
  .tblbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}
  @media(max-width:820px){ .rail{display:none;} }
  .rail .rsec > h4 { cursor:pointer; display:flex; align-items:center; justify-content:space-between; user-select:none; }
  .rail .rsec > h4::after { content:'▾'; color:var(--mut); font-size:11px; margin-left:8px; transition:transform .15s; }
  .rail .rsec.collapsed > h4::after { transform:rotate(-90deg); }
  .rail .rsec.collapsed > *:not(h4) { display:none !important; }
  .mlsempty { padding:60px 20px; text-align:center; color:var(--mut); }
  .starbtn { background:none; border:0; color:var(--mut); font-size:16px; line-height:1; cursor:pointer; padding:0; }
  .starbtn:hover, .starbtn.on { color:var(--warn); }
  #shortbtn.shorton { border-color:var(--warn); color:var(--warn); }
  .statsbar { display:flex; flex-wrap:wrap; gap:8px; align-items:center; padding:9px 22px; border-bottom:1px solid var(--line); }
  .statsbar:empty { display:none; }
  .statpill { font-size:12px; color:var(--ink); background:var(--card); border:1px solid var(--line); border-radius:8px; padding:4px 10px; }
  .statpill .sl { color:var(--mut); } .statpill b { font-variant-numeric:tabular-nums; }
  table.mls td.startd, table.mls th.starth { width:46px; white-space:nowrap; text-align:center; padding:5px 4px; }
  .copybtn { background:none; border:0; color:var(--mut); font-size:14px; line-height:1; cursor:pointer; padding:0; }
  .copybtn:hover { color:var(--blue); }
  #heat { background:var(--card); color:var(--ink); border:1px solid var(--line); border-radius:9px; padding:8px 10px; font-size:13px; }
  .heatleg { display:none; align-items:center; gap:5px; font-size:11px; color:var(--mut); }
  .heatleg.on { display:inline-flex; }
  .heatleg .hb { width:52px; height:8px; border-radius:4px; background:linear-gradient(90deg,hsl(0,70%,45%),hsl(60,70%,45%),hsl(120,70%,45%)); }
</style>
<link rel="stylesheet" href="/crcp-theme.css">
  <link rel="stylesheet" href="/three-view.css">
  <script src="/three-view.js"></script>
  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
</head>
<body>
<header>
  <h1><b>LA County Listings</b> · Residential + CRE <span style="font-size:11px;font-weight:400;color:var(--mut)">· Redfin-sourced</span></h1>
  <div class="nav"><a href="/">← Explorer</a><a href="/deals-flow.html" title="Closed deal flow — public-record sales + FHA + SEC REIT">💰 Deal Flow</a><a href="/map.html">🗺️ Map</a><a href="/licensed-agents.html" title="Real-estate agents &amp; brokers verified against the CA DRE public license roll">🎫 Licensed Agents</a><a href="/crcp.html">🛰️ CRCP</a></div>
  <span class="spacer"></span>
  <input class="search" id="q" type="text" placeholder="search all fields — address, city, firm, type, agent, source, year… (space = AND)">
  <div class="toggle tv-seg" id="view"><button data-v="grid">▦ Grid</button><button data-v="list">☰ List</button><button data-v="table" class="on">▤ Table</button></div>
  <select id="sortsel" title="Sort listings"></select>
  <label id="denswrap" title="Card density — grid view">⤢<input type="range" id="density" min="1" max="10" step="1"></label>
  <button class="csvbtn" id="shortbtn" title="Show only your shortlisted (starred) listings — shared with the Explorer">⭐ Shortlist (0)</button>
  <select id="heat" title="Color rows red (low) → green (high) by a metric"><option value="none">Heat: off</option><option value="cap">Heat: Cap rate</option><option value="score">Heat: Score</option></select>
  <span id="heatleg" class="heatleg">low <span class="hb"></span> high · <span class="hl"></span></span>
  <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="padding:9px 18px 10px;border-bottom:1px solid var(--line);background:var(--headbg,var(--card));position:sticky;top:0;z-index:39;">
  <div style="display:flex;align-items:center;gap:9px;">
    <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”  ·  “unwarrantable condos”  ·  “multifamily under $2M in Long Beach”  ·  “retail 6% cap”  (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:26%"></span>
  </div>
  <div id="chatex" style="display:flex;flex-wrap:wrap;gap:6px;margin-top:7px;padding-left:26px;font-size:11px;color:var(--mut);align-items:center;">
    <span style="opacity:.7">Try:</span>
    <button class="chatchip" data-q="4 units in Los Angeles">4 units in Los Angeles</button>
    <button class="chatchip" data-q="unwarrantable condos">unwarrantable condos</button>
    <button class="chatchip" data-q="multifamily under $2M in Long Beach">multifamily under $2M in Long Beach</button>
    <button class="chatchip" data-q="retail 6% cap">retail 6% cap</button>
    <button class="chatchip" data-q="office in Pasadena">office in Pasadena</button>
    <button class="chatchip" data-q="non-QM condos">non-QM condos</button>
    <button class="chatchip" data-q="built after 2000">built after 2000</button>
    <button class="chatchip" data-q="5-10 units in Las Vegas">5-10 units in Las Vegas</button>
  </div>
</div>
<style>.chatchip{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:20px;padding:3px 11px;font-size:11px;cursor:pointer;transition:all .12s;} .chatchip:hover{border-color:var(--acc,#3b82f6);color:var(--acc,#3b82f6);}</style>
<div class="shell">
<aside class="rail">
  <div class="rsec" id="fieldsSec"><h4>Fields / columns — show / hide · reorder</h4>
    <div class="fieldtoggles" id="fFields"></div>
    <div style="margin-top:8px"><button class="minibtn" id="fAll">All</button><button class="minibtn" id="fNone">None</button><button class="minibtn" id="fReset" title="Reset columns to the tight default — order, widths &amp; visibility">↺ Reset</button></div>
    <div class="mut" style="font-size:10.5px;margin-top:7px;line-height:1.35">Drag a row by its ⠿ handle to reorder columns. In Table view you can also drag a column header to reorder · its right edge to resize · click to sort.</div>
  </div>
  <div class="rsec"><h4>Asset class</h4><div class="chips" id="fType"></div>
    <div style="margin-top:9px;display:flex;flex-wrap:wrap;gap:6px;align-items:center"><span class="chip" id="listedChip" style="border-color:rgba(63,185,80,.5);color:var(--acc)" title="Homes newly on the market within the day window">🆕 Just Listed</span><span class="chip" id="soldChip" style="border-color:rgba(88,166,255,.5);color:var(--blue)" title="Closed escrow — pulled listings matched to a recorded sale within the day window">✅ Just Sold</span><span style="display:inline-flex;align-items:center;gap:4px;font-size:11px;color:var(--mut)">within <input type="number" id="recentDays" min="1" max="365" step="1" style="width:52px;background:var(--card);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:4px 6px;font-size:12px" title="Day window for Just Listed / Just Sold"> days</span><span class="chip" id="escrowChip" style="border-color:rgba(210,153,34,.5);color:var(--warn)" title="Currently in escrow — pending or contingent">🤝 In Escrow</span><span class="chip" id="fellChip" style="border-color:rgba(248,81,73,.45);color:var(--bad)" title="Back on the market — was pending/contingent, now active again">↩️ Fell Out of Escrow</span><span class="chip" id="cutChip" style="border-color:rgba(255,166,0,.5);color:#ffa600" title="Price reduced below its prior price within the day window">💸 Price Cut</span></div>
    <div style="margin-top:9px;display:flex;flex-wrap:wrap;gap:6px"><span class="chip" id="nqChip" data-warr="nonqm" style="border-color:rgba(188,140,255,.5);color:#bc8cff" title="Non-QM target: unwarrantable condos + small multifamily (1–9u) + mixed-use">◆ Non-QM</span><span class="chip" id="unwChip" data-warr="unwarrantable" style="border-color:rgba(248,81,73,.4);color:var(--bad)" title="Filter the table to non-FHA-approved condos (non-QM target)">⚑ Unwarrantable condos</span><a class="navchip" href="/condos" title="Full condo drill-down page">condos page →</a></div></div>
  <div class="rsec"><h4>Status</h4><div class="chips" id="fStatus"></div></div>
  <div class="rsec"><h4>Submarket / city</h4><div class="chips" id="fCity"></div></div>
  <div class="rsec"><h4>Source of truth</h4><div class="chips" id="fSource"></div><div style="font-size:10px;color:var(--mut);margin-top:5px;line-height:1.4">firm-direct = the brokerage's own listing · broker-of-record = real listing broker known · aggregator = CREXi/portal only</div></div>
  <div class="rsec"><h4>Brokerage <span style="font-size:10px;font-weight:400;color:var(--mut)">(listing broker)</span></h4><div class="chips" id="fFirm"></div></div>
  <div class="rsec"><h4>Price</h4><div class="row2"><div class="fld"><label>Min $</label><input type="number" id="pMin" placeholder="any" step="50000"></div><div class="fld"><label>Max $</label><input type="number" id="pMax" placeholder="any" step="50000"></div></div></div>
  <div class="rsec"><h4>Units / size</h4><div class="row2"><div class="fld"><label>Min units</label><input type="number" id="uMin" placeholder="any" min="0" step="1"></div><div class="fld"><label>Max units</label><input type="number" id="uMax" placeholder="any" min="0" step="1"></div></div>
    <div class="row2" style="margin-top:8px"><div class="fld"><label>Built after</label><input type="number" id="yMin" placeholder="any" min="1900" max="2026" step="1"></div><div class="fld"><label>Built before</label><input type="number" id="yMax" placeholder="any" min="1900" max="2026" step="1"></div></div>
    <div style="display:flex;gap:8px;align-items:center;margin-top:10px"><button class="gobtn" id="applyBtn" title="Apply the price / units / year values (or press Enter)">▶ Go</button><button class="minibtn" id="clearNum" title="Clear price / units / year">Clear</button><span id="stagedFlag" style="font-size:10px;color:var(--warn);display:none">● unapplied</span></div>
    <div style="font-size:10px;color:var(--mut);margin-top:6px">Set price / units / year, then hit <b>Go</b> (or press Enter).</div></div>
</aside>
<div class="raildiv" id="raildiv" title="Drag to resize the filters / records · double-click to collapse or restore"></div>
<div class="wrap">
  <div class="tblbar" id="tblbar">
    <button class="csvbtn" id="csv2" title="Download the current filtered rows as a CSV spreadsheet">⬇ Download CSV</button>
    <span class="mut" style="font-size:11px">Drag the <b>left</b> bar (filters↔records) to reclaim space · drag the <b>right</b> edge bar to widen the columns wider than the screen (scrolls) · double-click either to reset.</span>
    <span id="acctbar" style="margin-left:auto;display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:12px"></span>
  </div>
  <div class="statsbar" id="statsbar"></div>
  <div id="tableView" class="tblwrap"><table class="mls" data-no-sort><thead id="thead"></thead><tbody id="tbody"></tbody></table></div>
  <div id="gridView" class="grid hide"></div>
  <div id="listView" class="hide"></div>
  <div id="moreWrap" style="text-align:center;padding:16px 0"></div>
</div>
<div class="recgrip" id="recgrip" title="Drag left to widen the records / columns (table scrolls past the edge) · double-click to reset to fit"></div>
</div>
<div id="ov"><div id="modal"><div id="modalRsz" title="Drag to widen the panel · double-click to reset"></div><button class="ph-x" aria-label="Close" onclick="document.getElementById('ov').classList.remove('on')">✕</button><div id="mbody"></div></div></div>
<script>
// Day/Night theme — pre-applied before first paint (see head snippet).
// apply() only PAINTS — does NOT persist (passive loads must not lock in a theme;
// only an explicit toggle click below saves a pref, so everyone gets the Day default).
(function(){
  var root=document.documentElement;
  function applyTheme(t){ root.setAttribute('data-theme',t); }
  var cur; try{ cur=localStorage.getItem('crcp-theme'); }catch(e){}
  applyTheme(cur==='dark'?'dark':'light');
  // Wire up the toggle button injected by crcp-theme.js (defer — may not exist yet;
  // observe the header for it and intercept its click to persist crcp-theme).
  function wireToggle(){
    var btn=document.getElementById('crcpThemeToggle'); if(!btn||btn.__wired2) return;
    btn.__wired2=true;
    btn.addEventListener('click',function(e){
      e.stopImmediatePropagation();   // suppress crcp-theme.js's own handler so we don't double-toggle
      var nt=root.getAttribute('data-theme')==='light'?'dark':'light';
      applyTheme(nt); try{ localStorage.setItem('crcp-theme',nt); }catch(e){}
    },true);   // capture phase — fires before crcp-theme.js's own click handler
  }
  if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',wireToggle); else wireToggle();
  // also wire after defer scripts finish (crcp-theme.js injects the button on DOMContentLoaded)
  document.addEventListener('DOMContentLoaded',wireToggle);
})();
</script>
<script>
const $=s=>document.querySelector(s);
const esc=s=>(s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;');
const money=n=>(n==null||n==='')?'—':'$'+Number(n).toLocaleString();
const num=n=>(n==null||n==='')?'—':Number(n).toLocaleString();
// Column spec: key, label, type (s=string,n=number,money,pct,link,status), accessor
const COLS=[
  {k:'rank',l:'#',t:'n'},
  {k:'address',l:'Address',t:'s'},
  {k:'city',l:'City',t:'s'},
  {k:'zip',l:'ZIP',t:'s'},
  {k:'type',l:'Type',t:'s'},
  {k:'price',l:'Price',t:'money'},
  {k:'units',l:'Units',t:'n'},
  {k:'sqft',l:'SqFt',t:'n'},
  {k:'ppu',l:'$/Unit',t:'money',calc:r=>r.units?Math.round(r.price/r.units):null},
  {k:'pps',l:'$/SqFt',t:'money',calc:r=>r.sqft?Math.round(r.price/r.sqft):null},
  {k:'cap_rate',l:'Cap %',t:'pct'},
  {k:'area_rent',l:'Area Rent/mo',t:'money'},
  {k:'area_yield',l:'Area GRY %',t:'pct'},
  {k:'year_built',l:'Year',t:'n'},
  {k:'rent_control',l:'Rent Ctrl',t:'s'},
  {k:'status',l:'Status',t:'status'},
  {k:'mkt',l:'Market',t:'s'},
  {k:'dom',l:'Days on Mkt',t:'n'},
  {k:'prev_price',l:'Prev $',t:'money'},
  {k:'off_date',l:'Pulled',t:'s'},
  {k:'sold_price',l:'Sold $',t:'money'},
  {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:'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'},
  {k:'financeScore',l:'Finance',t:'n'},
  {k:'composite',l:'Score',t:'n'},
  {k:'source',l:'Listing',t:'link'},
];
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,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); };
// shared shortlist with the Explorer (same localStorage key + same listing ids)
const SHORT=new Set((function(){ try{ return JSON.parse(localStorage.getItem('cre_shortlist')||'[]'); }catch(e){ return []; } })());
function saveShort(){ try{ localStorage.setItem('cre_shortlist', JSON.stringify([...SHORT])); }catch(e){} }
function toggleStar(id){ SHORT.has(id)?SHORT.delete(id):SHORT.add(id); saveShort(); }
function updateShortBtn(){ const sb=$('#shortbtn'); if(sb){ sb.textContent='⭐ Shortlist ('+SHORT.size+')'; sb.classList.toggle('shorton',F.shortlist); } }
// pagination (perf, parity with the Explorer)
let _rows=[], _cols=[], _shown=0;
const PAGE=()=> view==='table'?200:view==='list'?120:60;
const star=r=>`<button class="starbtn ${SHORT.has(r.id)?'on':''}" data-star="${r.id}" aria-label="${SHORT.has(r.id)?'Remove from':'Add to'} shortlist" aria-pressed="${SHORT.has(r.id)}" title="Shortlist">★</button><button class="copybtn" data-copy="${r.id}" aria-label="Copy summary" title="Copy summary">📋</button>`;
function paintBody(){
  const rows=_rows.slice(0,_shown), cols=_cols;
  const show=id=>['#tableView','#gridView','#listView'].forEach(s=>$(s).classList.toggle('hide',s!==id));
  if(view==='table'){
    show('#tableView');
    $('#tbody').innerHTML=rows.map(r=>`<tr class="prow" data-addr="${esc(r.address||'')}" data-city="${esc(r.city||'')}" style="cursor:pointer"><td class="startd">${star(r)}</td>`+cols.map(c=>{
      const cls=(c.t==='money'||c.t==='n'||c.t==='pct')?' class="n"':''; return `<td${cls}>${cellHTML(r,c)}</td>`;
    }).join('')+'</tr>').join('');
    const csig=cols.map(c=>c.k).join(',');
    if(csig!==lastColSig){ lastColSig=csig; reinitResize(); }
    else if(window.ColResize && window.ColResize.reattach){ window.ColResize.reattach(document.querySelector('#tableView table.mls')); } // resize handles survive sort/search re-render (TK-10089)
  } else if(view==='list'){
    // COMPACT LIST — one scannable row per property, every VISIBLE column as inline
    // key/value pairs (reuses cellHTML so all formatting matches table + cards).
    // Rows keep the .prow + data-addr/data-city so the existing property-history
    // click delegation on document just works. TK-10526 additive 3rd view.
    show('#listView');
    const metaCols=cols.filter(c=>!['address','city','zip','type'].includes(c.k));
    $('#listView').innerHTML='<div class="tv-rows">'+rows.map(r=>`<div class="tv-lrow prow" data-addr="${esc(r.address||'')}" data-city="${esc(r.city||'')}">`
      +`<div class="tv-lmain">${star(r)} <span class="tv-la">${esc(r.address||'—')}</span><span class="tv-lsub">${esc(r.city||'')} ${esc(r.zip||'')}${r.type?' · '+esc(r.type):''}</span></div>`
      +`<div class="tv-lmeta">`+metaCols.map(c=>`<span class="tv-lm"><span class="tv-lmk">${esc(c.l)}</span>${cellHTML(r,c)}</span>`).join('')+`</div></div>`).join('')+'</div>';
  } else {
    show('#gridView');
    const bodyCols=cols.filter(c=>!['address','city','zip','type'].includes(c.k));
    $('#gridView').innerHTML=rows.map(r=>`<div class="gc prow" data-addr="${esc(r.address||'')}" data-city="${esc(r.city||'')}" style="cursor:pointer">
      <div style="display:flex;justify-content:space-between;gap:8px"><div class="addr">${esc(r.address||'—')}</div>${star(r)}</div><div class="sub">${esc(r.city||'')} ${esc(r.zip||'')} · ${esc(r.type||'')}</div>${r.first_seen?`<div class="when" title="${esc(String(r.first_seen))}" style="font-size:10px;color:var(--mut,#8b949e);margin-top:4px">🕓 First seen ${esc(new Date(r.first_seen).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}))}</div>`:''}`
      +bodyCols.map(c=>`<div class="row"><span class="k">${esc(c.l)}</span><span class="v">${cellHTML(r,c)}</span></div>`).join('')
      +`</div>`).join('');
  }
  const mw=$('#moreWrap'); if(mw) mw.innerHTML=_shown<_rows.length?`<button class="csvbtn" id="showmore">Show ${Math.min(PAGE(),_rows.length-_shown)} more · showing ${_shown} of ${_rows.length}</button>`:'';
  applyHeat(rows);
  wireAutoMore();   // infinite scroll: auto-load the next page as #showmore nears view
}
// Convert the "show more" button into infinite scroll — auto-click it when it scrolls near
// view (reuses the existing #showmore handler → _shown+=PAGE → paintBody). The manual button
// still works as a fallback where IntersectionObserver is unavailable.
let _moreIO=null, _moreBusy=false;
function wireAutoMore(){
  const btn=document.getElementById('showmore'); if(!btn) return;
  if(typeof IntersectionObserver==='undefined') return;
  if(!_moreIO) _moreIO=new IntersectionObserver(es=>{ if(_moreBusy) return; if(es.some(e=>e.isIntersecting)){ const b=document.getElementById('showmore'); if(b){ _moreBusy=true; b.click(); setTimeout(()=>{_moreBusy=false;},60); } } },{rootMargin:'500px 0px'});
  _moreIO.disconnect(); _moreIO.observe(btn);
}
// heat cues + copy summary (parity with the Explorer)
let heat=localStorage.getItem('cre_heat_mls')||'none';
function heatVal(r){ return heat==='cap'?(r.cap_rate!=null&&r.cap_rate!==''?+r.cap_rate:null):heat==='score'?(r.composite!=null?+r.composite:null):null; }
function heatColor(t){ t=Math.max(0,Math.min(1,t)); return `hsl(${Math.round(t*120)},70%,45%)`; }
function applyHeat(rows){
  const lg=$('#heatleg'); if(lg){ lg.classList.toggle('on',heat!=='none'); if(heat!=='none') lg.querySelector('.hl').textContent={cap:'Cap rate',score:'Score'}[heat]; }
  if(heat==='none') return;
  const vals=rows.map(heatVal).filter(v=>v!=null&&isFinite(v)); if(!vals.length) return;
  const min=Math.min(...vals),max=Math.max(...vals),span=(max-min)||1;
  rows.forEach(r=>{ const v=heatVal(r); const cell=document.querySelector(`.startd [data-star="${r.id}"]`)?.closest('td'); if(!cell) return; cell.style.boxShadow=(v==null||!isFinite(v))?'':'inset 5px 0 0 '+heatColor((v-min)/span); });
}
function rowSummary(r){ const L=[];
  L.push(`${r.address||''}, ${r.city||''} CA ${r.zip||''}`.trim());
  const l2=[r.type, r.units?`${r.units} unit${r.units>1?'s':''}`:null, r.price?money(r.price):null].filter(Boolean).join(' · '); if(l2)L.push(l2);
  const m=[]; if(r.cap_rate!=null&&r.cap_rate!=='') m.push('Cap '+r.cap_rate+'%'); if(r.units&&r.price) m.push('$'+Math.round(r.price/r.units).toLocaleString()+'/unit'); if(r.sqft&&r.price) m.push('$'+Math.round(r.price/r.sqft).toLocaleString()+'/SF'); if(m.length)L.push(m.join(' · '));
  const ex=[]; if(r.year_built) ex.push('Built '+r.year_built); if(r.beds!=null) ex.push((r.beds)+'bd/'+(r.baths??'—')+'ba'); if(r.warrantable_status) ex.push('Warrantability: '+r.warrantable_status); if(ex.length)L.push(ex.join(' · '));
  const _lu=brokerListing(r); if(_lu.mode==='listing') L.push(_lu.href); else { const _bt=[_lu.firm,_lu.agent].filter(Boolean).join(' — '); if(_bt) L.push('Broker: '+_bt); } return L.join('\n');
}
function copySummary(id,btn){ const r=DATA.find(x=>x.id===id); if(!r) return; const done=()=>{ if(btn){ btn.textContent='✓'; setTimeout(()=>btn.textContent='📋',1200); } }; try{ navigator.clipboard.writeText(rowSummary(r)).then(done).catch(done); }catch(e){ done(); } }
function median(a){ if(!a.length) return null; const s=a.slice().sort((x,y)=>x-y); const m=Math.floor(s.length/2); return s.length%2?s[m]:(s[m-1]+s[m])/2; }
function renderStats(rows){ const sb=$('#statsbar'); if(!sb) return; if(!rows.length){ sb.innerHTML=''; return; }
  const prices=rows.map(r=>+r.price).filter(v=>v>0);
  const ppu=rows.map(r=>r.units?+r.price/r.units:null).filter(v=>v>0);
  const pps=rows.map(r=>r.sqft?+r.price/r.sqft:null).filter(v=>v>0);
  const caps=rows.map(r=>r.cap_rate).filter(v=>v!=null&&v!=='').map(Number).filter(v=>!isNaN(v));
  const yrs=rows.map(r=>+r.year_built).filter(v=>v>1800);
  const rents=rows.map(r=>+r.area_rent).filter(v=>v>0);
  const ylds=rows.map(r=>+r.area_yield).filter(v=>v>0);
  const pill=(l,v)=>v==null?'':`<span class="statpill"><span class="sl">${l}</span> <b>${v}</b></span>`;
  sb.innerHTML=pill('Count',rows.length)
    +pill('Median price',prices.length?'$'+Math.round(median(prices)).toLocaleString():null)
    +pill('Median $/unit',ppu.length?'$'+Math.round(median(ppu)).toLocaleString():null)
    +pill('Median $/SF',pps.length?'$'+Math.round(median(pps)).toLocaleString():null)
    +pill('Avg cap',caps.length?(caps.reduce((a,b)=>a+b,0)/caps.length).toFixed(2)+'% ('+caps.length+')':null)
    +pill('Avg area rent',rents.length?'$'+Math.round(rents.reduce((a,b)=>a+b,0)/rents.length).toLocaleString()+'/mo':null)
    +pill('Median area GRY',ylds.length?median(ylds).toFixed(2)+'%':null)
    +pill('Median year',yrs.length?Math.round(median(yrs)):null);
}
// Listing lifecycle: status 'off_market' means it left Redfin's active feed (pulled). disposition
// 'sold' (cross-referenced vs closed_sale) vs 'withdrawn'. Surfaced as Status = Active/Sold/Withdrawn.
const isEscrow=v=>/pending|contingent/i.test(v||'');
// Status column shows lifecycle first, then In Escrow for active-but-under-contract listings.
function lifeStatus(x){ if(x.status==='off_market') return x.disposition==='sold'?'Sold':'Withdrawn'; if(isEscrow(x.market_status)) return 'In Escrow'; return 'Active'; }
function mapCondo(c){ return { id:c.id, address:c.address, city:c.city, zip:c.zip, type:'Condo', price:+c.price||0, units:1, cap_rate:null, year_built:c.year_built||null, status:lifeStatus(c), first_seen:c.created_at||null, prev_price:c.prev_price?+c.prev_price:null, price_cut_at:c.price_changed_at?String(c.price_changed_at).slice(0,10):null, dom:c.days_on_market!=null?+c.days_on_market:null, listed_date:c.listed_date?String(c.listed_date).slice(0,10):null, mkt:c.market_status||null, prev:c.prev_market_status||null, off_date:c.off_market_at?String(c.off_market_at).slice(0,10):null, disposition:c.disposition||null, sold_price:c.sold_price?+c.sold_price:null, sold_date:c.sold_date?String(c.sold_date).slice(0,10):null, source:c.source, firm:c.firm_name||null, beds:c.beds, baths:c.baths, sqft:c.sqft, hoa:c.hoa, warrantable_status:c.warrantable_status, broker_name:c.broker_name, project_name:c.project_name }; }
function mapSfr(s){ return { id:s.id, address:s.address, city:s.city, zip:s.zip, type:'Single-Family', price:+s.price||0, units:1, cap_rate:null, year_built:s.year_built||null, status:lifeStatus(s), first_seen:s.created_at||null, prev_price:s.prev_price?+s.prev_price:null, price_cut_at:s.price_changed_at?String(s.price_changed_at).slice(0,10):null, dom:s.days_on_market!=null?+s.days_on_market:null, listed_date:s.listed_date?String(s.listed_date).slice(0,10):null, mkt:s.market_status||null, prev:s.prev_market_status||null, off_date:s.off_market_at?String(s.off_market_at).slice(0,10):null, disposition:s.disposition||null, sold_price:s.sold_price?+s.sold_price:null, sold_date:s.sold_date?String(s.sold_date).slice(0,10):null, source:s.source, firm:s.firm_name||null, beds:s.beds, baths:s.baths, sqft:s.sqft, broker_name:s.broker_name||null }; }
// START TIGHT (Steve rule 2026-07-31): only the essentials show on first load; the user
// toggles the rest on from the rail. A saved pref in VISCOL always wins.
const DEFAULT_ON=new Set(['address','city','type','price','cap_rate','status','sold_date','first_seen']);
let VISCOL=(function(){let s={};try{s=JSON.parse(localStorage.getItem('mlsCols')||'{}');}catch(e){}return s;})();
function colVis(k){ if(k in VISCOL) return !!VISCOL[k]; return DEFAULT_ON.has(k); }
// Draggable column ORDER (Steve rule: move columns around). Keys in display order;
// auto-discovered/new keys are appended, stale keys pruned. Persisted per browser.
let COLORDER=(function(){let a=[];try{a=JSON.parse(localStorage.getItem('mlsColOrder')||'[]');}catch(e){}return Array.isArray(a)?a:[];})();
function syncColOrder(){
  const keys=COLS.map(c=>c.k);
  COLORDER=COLORDER.filter(k=>keys.includes(k));                    // prune removed
  keys.forEach(k=>{ if(!COLORDER.includes(k)) COLORDER.push(k); }); // append new
}
function orderedCols(){ syncColOrder(); return COLORDER.map(k=>COLS.find(c=>c.k===k)).filter(Boolean); }
function visCols(){ return orderedCols().filter(c=>colVis(c.k)); }
// Re-apply the account's saved view keys (columns / order / sort / theme) into the LIVE in-memory
// state and repaint — the reload-free replacement for boot()'s old location.reload(). Those keys are
// otherwise only read at script-parse time, which is why the original code reloaded the whole page to
// make a freshly-synced view take effect; that reload wiped the first paint ("data flashes then blank")
// and looped where sessionStorage doesn't persist. Defined at top level (NOT inside the accounts IIFE)
// so `render` here resolves to the global TABLE render, not the IIFE's account-bar render of the same name.
function rehydrateView(){
  try{ const s=JSON.parse(localStorage.getItem('mlsCols')||'{}'); VISCOL=(s&&typeof s==='object')?s:{}; }catch(e){ VISCOL={}; }
  try{ const a=JSON.parse(localStorage.getItem('mlsColOrder')||'[]'); COLORDER=Array.isArray(a)?a:[]; }catch(e){ COLORDER=[]; }
  const sk=localStorage.getItem('mlsSortKey'); if(sk) sortKey=sk;
  const sd=localStorage.getItem('mlsSortDir'); if(sd!==null&&sd!=='') sortDir=(+sd<0?-1:1);
  const th=localStorage.getItem('crcp-theme'); 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','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){} }
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(){
  const tbl=document.querySelector('#tableView table.mls'); if(!tbl) return;
  const cg=tbl.querySelector('colgroup[data-cr]'); if(cg) cg.remove();
  delete tbl.dataset.crDone; tbl.style.tableLayout=''; tbl.style.width='';
  if(window.ColResize && window.ColResize.refresh) window.ColResize.refresh();
}
function tog(set,v){ set.has(v)?set.delete(v):set.add(v); }
// Discover EVERY scalar data point present in the feed and expose each as a toggleable column (default hidden).
function autoCols(){
  const known=new Set(COLS.map(c=>c.k)); const skip=new Set(['id','finance','qwen','deal','warrant_signals','listings_json','tx_history','risks','_hay']);   // _hay = the cached search haystack, never a column
  const seen=new Set();
  // sample from BOTH ends so appended datasets (e.g. condos) contribute their unique fields
  DATA.slice(0,50).concat(DATA.slice(-50)).forEach(r=>Object.keys(r||{}).forEach(k=>{
    if(known.has(k)||skip.has(k)||seen.has(k)) return; const v=r[k]; if(v==null) return;
    if(typeof v==='object') return; seen.add(k);
    const isNum = typeof v==='number' || (v!==''&&!isNaN(+v));
    COLS.push({k, l:k.replace(/_/g,' ').replace(/\b\w/g,ch=>ch.toUpperCase()).slice(0,20), t:isNum?'n':'s', def:0});
  }));
}
function buildRail(){
  const tc={},cc={},sc={},oc={},fc={};
  DATA.forEach(r=>{ if(r.type)tc[r.type]=(tc[r.type]||0)+1; if(r.city)cc[r.city]=(cc[r.city]||0)+1; const s=String(r.status||'').startsWith('Active')?'Active':'Off-market'; sc[s]=(sc[s]||0)+1; if(r.source_of_truth)oc[r.source_of_truth]=(oc[r.source_of_truth]||0)+1; if(r.firm&&!/^crexi$/i.test(r.firm))fc[r.firm]=(fc[r.firm]||0)+1; });
  const chip=(obj,attr,limit)=>Object.entries(obj).sort((a,b)=>b[1]-a[1]).slice(0,limit||99).map(([k,c])=>`<span class="chip" data-${attr}="${esc(k)}">${esc(k)}<span class="ct">${c}</span></span>`).join('');
  $('#fType').innerHTML=chip(tc,'ty'); $('#fStatus').innerHTML=chip(sc,'st'); $('#fCity').innerHTML=chip(cc,'cy',16); $('#fSource').innerHTML=chip(oc,'so'); $('#fFirm').innerHTML=chip(fc,'fm',24);
  buildFieldToggles();
}
// Column show/hide + in-panel search & drag-reorder via the shared column-manager.js
// drop-in (TK-10120), running as a VIEW over mls's own VISCOL/COLORDER state.
let __cm=null;
function buildFieldToggles(){
  if(!window.ColumnManager){ // fallback if the module hasn't loaded (keeps the page usable)
    $('#fFields').innerHTML=orderedCols().map(c=>`<label class="ftog" draggable="true" data-fk="${c.k}"><span class="fdrag" title="Drag to reorder column">⠿</span><input type="checkbox" data-ck="${c.k}" ${colVis(c.k)?'checked':''}><span class="flbl">${esc(c.l)}</span></label>`).join(''); return; }
  const groupOf=k=>DEFAULT_ON.has(k)?'Shown by default':'More columns';
  const cols=COLS.map(c=>({k:c.k,l:c.l,g:groupOf(c.k)}));
  if(!__cm){
    __cm=ColumnManager.init({ mount:'#fFields', footer:false, hint:false, cols,
      state:{ visible:k=>colVis(k), setVisible:(k,v)=>{VISCOL[k]=v;saveCols();},
              order:()=>{syncColOrder();return COLORDER.slice();}, setOrder:a=>{COLORDER=a;saveColOrder();},
              reset:()=>{VISCOL={};saveCols();COLORDER=[];saveColOrder();} },
      onChange:()=>render() });
  } else { __cm.setCols(cols); }
}
function isNonQM(r){ const t=(r.type||'').toLowerCase();
  if(r.type==='Condo' && r.warrantable_status && r.warrantable_status!=='fha_approved') return true;
  if(/multi/.test(t) && r.units>=1 && r.units<=9) return true;
  if(/mixed/.test(t)) return true; return false; }
function anySelection(){ return F.types.size||F.cities.size||F.status.size||F.source.size||F.firms.size||F.pmin!=null||F.pmax!=null||F.umin!=null||F.umax!=null||F.ymin!=null||F.ymax!=null||F.unwarrantable||F.nonqm||F.shortlist||F.justsold||F.justlisted||F.inescrow||F.fellout||F.pricecut||(q&&q.length); }
function passFacets(r){
  if(F.types.size && !F.types.has(r.type)) return false;
  if(F.cities.size && !F.cities.has(r.city)) return false;
  if(F.source.size && !F.source.has(r.source_of_truth)) return false;
  if(F.firms.size && !F.firms.has(r.firm)) return false;
  if(F.status.size){ const s=String(r.status||'').startsWith('Active')?'Active':'Off-market'; if(!F.status.has(s)) return false; }
  if(F.pmin!=null && !(+r.price>=F.pmin)) return false;
  if(F.pmax!=null && !(+r.price<=F.pmax)) return false;
  if(F.umin!=null && !(r.units!=null && +r.units>=F.umin)) return false;
  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).
  if(F.justlisted && !(r.status!=='Sold' && r.status!=='Withdrawn' && listAge(r)<=F.recentDays)) return false;
  // Just Sold / Closed escrow = recently LEFT the market (off_market_at) with a matched sale.
  if(F.justsold && !(r.disposition==='sold' && daysSince(r.off_date)<=F.recentDays)) return false;
  // In Escrow = currently pending/contingent. Fell Out = was pending/contingent, now back on market.
  if(F.inescrow && !isEscrow(r.mkt)) return false;
  if(F.fellout && !(isEscrow(r.prev) && !isEscrow(r.mkt) && r.status!=='Sold' && r.status!=='Withdrawn')) return false;
  // Price Cut = current price dropped below its previous price, changed within N days.
  if(F.pricecut && !(r.prev_price>0 && +r.price<+r.prev_price && daysSince(r.price_cut_at)<=F.recentDays)) return false;
  if(F.shortlist && !SHORT.has(r.id)) return false;
  return true;
}
const cval=(r,c)=>c.calc?c.calc(r):r[c.k];
// Doctrine (docs/SOURCING.md): the aggregator is a breadcrumb to the broker, NEVER a link we show.
// Resolve the canonical CLICK-THROUGH: a firm-direct source wins; else the broker's own site; else no link.
const _AGG=['crexi','redfin','zillow','costar','loopnet','realtor','myelisting'];
const _host=u=>{try{return new URL(u).hostname.replace(/^www\./,'');}catch{return null;}};
const _isAgg=u=>{const h=_host(u);return !!(h&&_AGG.some(a=>h.includes(a)));};
// Agent OWN-site + phone bits (from the $0 agent-sites discovery), appended to the
// listing cell so every displayed agent ties to firm AND shows 📞 phone · 👤 own site
// (or a free on-demand "find" button). broker_url = firm site, handled separately.
function agentBits(r){
  if(!r.broker_agent) return '';
  const out=[];
  out.push(`<a href="/agent.html?name=${encodeURIComponent(r.broker_agent)}" target="_blank" rel="noopener noreferrer" title="Open ${esc(r.broker_agent)}'s own page — current &amp; past listings" style="font-weight:600">👤 ${esc(r.broker_agent)} ↗</a>`);
  if(r.agent_phone) out.push(`<a href="tel:${esc(r.agent_phone)}" title="the agent's phone">📞 ${esc(r.agent_phone)}</a>`);
  if(r.agent_site) out.push(`<a href="${esc(r.agent_site)}" target="_blank" rel="noopener noreferrer" title="the agent's own website">👤 agent ↗</a>`);
  else if(r.agent_site_status!=='no_url') out.push(`<button class="agent-find" data-a="${esc(r.broker_agent)}" data-f="${esc(r.broker_firm||'')}" data-c="${esc(r.city||'')}" title="find this agent's own site (free, local)" style="background:none;border:1px solid currentColor;border-radius:4px;padding:0 5px;cursor:pointer;font:inherit;color:inherit;opacity:.7">🔎 agent</button>`);
  return out.length?`<div style="margin-top:3px;display:flex;gap:8px;flex-wrap:wrap;font-size:11px">${out.join('')}</div>`:'';
}
function brokerListing(r){
  // 0. Harvested firm-direct deep link (broker's own property page) — highest-confidence source of truth
  if(r.firm_direct_url && !_isAgg(r.firm_direct_url)) return {mode:'listing',href:r.firm_direct_url,label:'View firm listing ↗',title:"the broker's own property page ("+(r.firm_direct_source||'firm-direct')+")"};
  // 1. Firm-direct property listing URL → honest "view this listing"
  if(r.source && !_isAgg(r.source)) return {mode:'listing',href:r.source,label:'View firm listing ↗',title:"the broker's own property listing"};
  // 2. Aggregator / unknown source → text block with optional firm site + always a find link
  const firm=r.broker_firm||r.firm||null;
  const agent=r.broker_agent||null;
  const firmSite=(r.broker_url && !_isAgg(r.broker_url))?r.broker_url:null;
  const find='https://www.google.com/search?q='+encodeURIComponent('"'+(r.address||'')+'" '+(r.city||'')+' '+(firm||'')+' commercial listing');
  return {mode:'text',firm,agent,firmSite,find};
}
function cellHTML(r,c){
  const v=cval(r,c);
  if(c.t==='money') return money(v);
  if(c.t==='pct') return v==null||v===''?'—':v+'%';
  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
    const fs=u.firmSite?`<a href="${esc(u.firmSite)}" target="_blank" rel="noopener noreferrer" title="the listing broker's own site">🏢 site ↗</a> `:'';
    return fs+`<a href="${esc(u.find)}" target="_blank" rel="noopener noreferrer" title="find this listing on the web">🔎 find</a>`+ab; }
  // Firm name links to the broker's own site (only when non-aggregator).
  if(c.k==='firm' && r.broker_url && !_isAgg(r.broker_url)) return `<a href="${esc(r.broker_url)}" target="_blank" rel="noopener noreferrer" title="the listing broker's own site">${esc(v==null||v===''?'—':v)} ↗</a>`;
  // Agent name → their own CRCP page (current + past listings).
  if(c.k==='broker_agent' && v) return `<a href="/agent.html?name=${encodeURIComponent(v)}" target="_blank" rel="noopener noreferrer" title="Open this agent's own page — current &amp; past listings">${esc(v)} ↗</a>`;
  return esc(v==null||v===''?'—':v);
}
// Cached per-row search haystack. filtered() otherwise re-stringifies EVERY field of EVERY row on EVERY
// render (sort/chip/heat/shortlist/show-more — 15+ call sites), not just on search — real cost at 25k+ rows
// (Cody Pass-2 gate). Built once after enrichment at load (below) + lazily for any late row; O(1) to read.
// Excludes '_hay' so it never stringifies into itself, and mirrors the old "search ALL fields" behavior.
function buildHay(r){ return Object.keys(r).map(k=>{ if(k==='_hay') return ''; const v=r[k]; return v==null?'':(typeof v==='object'?JSON.stringify(v):String(v)); }).join('  ').toLowerCase(); }
function filtered(){
  let rows=DATA.filter(passFacets);
  if(q){
    // Search ALL data (Steve rule 2026-07-31): every field the API returned on a row —
    // not just the defined/visible columns — incl. nested objects. Space = AND.
    const terms=q.toLowerCase().split(/\s+/).filter(Boolean);
    rows=rows.filter(r=>{
      const hay=r._hay || (r._hay=buildHay(r));
      return terms.every(t=>hay.includes(t));
    });
  }
  const c=COLS.find(x=>x.k===sortKey)||COLS[0];
  rows=rows.slice().sort((a,b)=>{
    let x=cval(a,c), y=cval(b,c);
    // missing values always sink to the bottom, regardless of sort direction
    const xm=(x==null||x===''), ym=(y==null||y==='');
    if(xm&&ym) return 0; if(xm) return 1; if(ym) return -1;
    const xn=(!isNaN(+x)), yn=(!isNaN(+y));
    if(xn&&yn){ x=+x; y=+y; } else { x=String(x).toLowerCase(); y=String(y).toLowerCase(); }
    if(x<y) return -1*sortDir; if(x>y) return 1*sortDir; return 0;
  });
  return rows;
}
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){
    $('#count').textContent='loading listings…';
    $('#statsbar').innerHTML=''; const _mw=$('#moreWrap'); if(_mw) _mw.innerHTML='';
    return;
  }
  const rows=filtered(); const cols=visCols();
  // If EVERY column is hidden (e.g. the field-panel "None" button, or a bad saved mlsCols), the old code
  // painted a star-only body that reads as "zero data". Show a legible, non-blank empty state instead —
  // WITHOUT destroying the user's column choice (a prior version reset VISCOL here, which silently undid
  // a deliberate "None" click).
  if(!cols.length){
    $('#count').textContent='All columns are hidden — open the ⚙ field panel on the left to show columns';
    ['#tableView','#gridView','#listView'].forEach(s=>$(s).classList.add('hide'));
    $('#statsbar').innerHTML=''; const _mw=$('#moreWrap'); if(_mw) _mw.innerHTML='';
    return;
  }
  renderStats(rows);
  $('#count').textContent = anySelection()
    ? `${rows.length.toLocaleString()} of ${DATA.length.toLocaleString()} properties`
    : `${rows.length.toLocaleString()} properties · filter on the left to narrow ↤`;
  // table head (full column set; independent of pagination)
  $('#thead').innerHTML='<tr><th class="starth">⭐</th>'+cols.map(c=>`<th data-k="${c.k}" draggable="true" title="Drag to reorder · click to sort" class="dragcol${sortKey===c.k?(sortDir>0?' asc':' desc'):''}">${esc(c.l)}</th>`).join('')+'</tr>';
  _rows=rows; _cols=cols; _shown=Math.min(PAGE(), rows.length);   // paginate the DOM; count+stats use the full set
  paintBody();
}
$('#thead').addEventListener('click',e=>{ const th=e.target.closest('th'); if(!th) return; const k=th.dataset.k; if(!k) return;
  if(sortKey===k) sortDir*=-1; else { sortKey=k; sortDir=(k==='address'||k==='city'||k==='firm'||k==='type')?1:-1; }
  localStorage.setItem('mlsSortKey',sortKey); localStorage.setItem('mlsSortDir',sortDir); if(window.__syncSortSel) window.__syncSortSel(); render(); });
// ── drag-to-reorder columns (Steve rule 2026-07-31: move columns around) ──
let dragKey=null;
$('#thead').addEventListener('dragstart',e=>{ const th=e.target.closest('th'); if(!th||!th.dataset.k) return;
  if(e.target.closest('.cr-handle')){ e.preventDefault(); return; }        // that grab is a resize, not a move
  dragKey=th.dataset.k; e.dataTransfer.effectAllowed='move'; try{e.dataTransfer.setData('text/plain',dragKey);}catch(_){}
  th.classList.add('dragging'); });
$('#thead').addEventListener('dragover',e=>{ if(!dragKey) return; const th=e.target.closest('th'); if(!th||!th.dataset.k||th.dataset.k===dragKey) return;
  e.preventDefault(); e.dataTransfer.dropEffect='move';
  const r=th.getBoundingClientRect(), after=(e.clientX-r.left)>r.width/2;
  th.classList.toggle('dropR',after); th.classList.toggle('dropL',!after); });
$('#thead').addEventListener('dragleave',e=>{ const th=e.target.closest('th'); if(th) th.classList.remove('dropL','dropR'); });
$('#thead').addEventListener('drop',e=>{ if(!dragKey) return; const th=e.target.closest('th'); if(!th||!th.dataset.k) return; e.preventDefault();
  const tgt=th.dataset.k; if(tgt!==dragKey){
    const r=th.getBoundingClientRect(), after=(e.clientX-r.left)>r.width/2;
    syncColOrder(); const from=COLORDER.indexOf(dragKey); if(from>=0){
      COLORDER.splice(from,1); let to=COLORDER.indexOf(tgt); if(after) to+=1; COLORDER.splice(to,0,dragKey);
      saveColOrder(); lastColSig=''; render(); if(__cm)__cm.refresh();
    }
  }
  dragKey=null; });
$('#thead').addEventListener('dragend',()=>{ document.querySelectorAll('#thead th.dragging,#thead th.dropL,#thead th.dropR').forEach(x=>x.classList.remove('dragging','dropL','dropR')); dragKey=null; });
// click a property → 100-yr history popup (address-only, no names)
async function propHistory(address,city){
  const ov=$('#ov'), mb=$('#mbody'); ov.classList.add('on'); mb.innerHTML='<div class="ph-mut">loading history for '+esc(address)+'…</div>';
  let d; try{ d=await (await fetch('/api/property-history?address='+encodeURIComponent(address)+'&city='+encodeURIComponent(city||''))).json(); }catch(e){ mb.innerHTML='<div class="ph-mut">error</div>'; return; }
  const a=d.assessor||d.assessorArchive;
  let h='<h3>'+(function(){const q=[address,city].filter(Boolean).join(', ');return q?'<a href="https://www.google.com/maps/search/?api=1&query='+encodeURIComponent(q)+'" target="_blank" rel="noopener noreferrer" title="Open in Google Maps">'+esc(address)+' 🗺️</a>':esc(address);})()+'</h3><div class="ph-sub">'+esc(city||'')+' · property history (address only — no names)</div>';
  if(a){ h+='<div class="ph-sec"><h4>Assessor parcel'+(a.roll_year?' ('+esc(a.roll_year)+' roll)':'')+'</h4><div class="ph-ci">'
    +(a.year_built?'<span>🏗 Built '+a.year_built+'</span>':'')
    +(a.bedrooms!=null?'<span>🛏 '+a.bedrooms+' bd</span>':'')+(a.bathrooms!=null?'<span>🛁 '+a.bathrooms+' ba</span>':'')
    +(a.sqft_main?'<span>📐 '+Number(a.sqft_main).toLocaleString()+' sqft</span>':'')+(a.units?'<span>🏠 '+a.units+' units</span>':'')
    +((a.use_desc2||a.use_desc1||a.use_description)?'<span>🏷 '+esc(a.use_desc2||a.use_desc1||a.use_description)+'</span>':'')
    +((a.roll_total_value||a.total_value)?'<span>💰 assessed '+money(a.roll_total_value||a.total_value)+'</span>':'')+'</div></div>'; }
  h+='<div class="ph-sec"><h4>Sold history ('+d.sales.length+')</h4>'+(d.sales.length?d.sales.map(x=>'<div class="ph-item"><span>'+esc(x.address)+(x.city?', '+esc(x.city):'')+'</span><span class="ph-mut">'+money(x.sold_price)+' · '+String(x.sold_date||'').slice(0,10)+(x.mls?' · MLS '+esc(x.mls):'')+'</span></div>').join(''):'<div class="ph-mut">no sold records on file for this address</div>')+'</div>';
  if(d.permits&&d.permits.length) h+='<div class="ph-sec"><h4>Permits ('+d.permits.length+')</h4>'+d.permits.map(p=>'<div class="ph-item"><span>'+esc(p.permit_type||p.use_description||'permit')+'</span><span class="ph-mut">'+esc(String(p.issue_date||'').slice(0,10))+(p.valuation?' · '+money(p.valuation):'')+'</span></div>').join('')+'</div>';
  if(!a && !d.sales.length && !(d.permits||[]).length) h+='<div class="ph-mut">No assessor record matched this address in the LA County parcel roll (2.4M parcels), and no sold or permit history is on file. Try the street number plus street name exactly as it appears on the deed.</div>';
  mb.innerHTML=h;
}
document.addEventListener('click',e=>{ const cp=e.target.closest('[data-copy]'); if(cp){ copySummary(cp.dataset.copy,cp); return; } const st=e.target.closest('[data-star]'); if(st){ toggleStar(st.dataset.star); st.classList.toggle('on',SHORT.has(st.dataset.star)); updateShortBtn(); if(F.shortlist) render(); return; }
  const row=e.target.closest('.prow'); if(row && e.target.tagName!=='A'){ propHistory(row.dataset.addr,row.dataset.city); }
  if(e.target.id==='ov'||e.target.classList.contains('ph-x')) $('#ov').classList.remove('on'); });
// Debounce the table search: render() runs filtered() over the FULL DATA set (stringify+substring every
// field of every row + a full sort) — on 25k+ rows that's real jank if it fires on every keystroke. Match
// the 160ms debounce the sibling global-search.js already uses. (yolo Pass 2, DTD-decided, Cody-gated.)
let _qT; $('#q').addEventListener('input',()=>{ clearTimeout(_qT); _qT=setTimeout(()=>{ q=$('#q').value.trim(); render(); },140); });
// deep-link ?q= (from the global nav search) — pre-fill + filter on load
(function(){ const _q=new URLSearchParams(location.search).get('q'); if(_q){ const b=$('#q'); if(b){ b.value=_q; q=_q.trim(); } } })();
// deep-link ?shortlist=sfv-pool — land here with the SFV $700k-$1.2M set already CHECKED OFF (starred) +
// the Shortlist filter on, so the email / pool-page "open on CRCP" link shows exactly those listings here.
// Named preset (not ~950 ids in the URL): fetch the current id set from /api/sfv-pool and star them.
// deep-link ?shortlist=sfv-pool — robust: don't race boot()/hydrateFilters(). Instead write the shortlist
// (cre_shortlist) + turn on the shortlist filter (mlsFilters.shortlist=1) into localStorage, then reload
// to a CLEAN /mls.html so the page's OWN native restore path (SHORT built from cre_shortlist at parse +
// hydrateFilters applying mlsFilters) shows exactly these listings — no timing race.
(function(){
  const sp=new URLSearchParams(location.search); if(sp.get('shortlist')!=='sfv-pool') return;
  fetch('/api/sfv-pool').then(r=>r.json()).then(j=>{
    const ids=[...(j.withPool||[]),...(j.withoutPool||[]),...(j.unknown||[])].map(r=>r.id).filter(Boolean);
    if(!ids.length){ location.replace('/mls.html'); return; }
    let cur=[]; try{ cur=JSON.parse(localStorage.getItem('cre_shortlist')||'[]'); }catch(e){}
    localStorage.setItem('cre_shortlist', JSON.stringify([...new Set([...cur, ...ids])]));
    let mf={}; try{ mf=JSON.parse(localStorage.getItem('mlsFilters')||'{}')||{}; }catch(e){}
    mf.shortlist=1; localStorage.setItem('mlsFilters', JSON.stringify(mf));
    location.replace('/mls.html');   // native restore path applies both on the clean reload
  }).catch(e=>{ console.warn('sfv-pool preset failed', e); location.replace('/mls.html'); });
})();
function exportCSV(){ const rows=filtered(); const cols=visCols(); const cv=(r,c)=>{let v=cval(r,c); v=(v==null?'':String(v)); return '"'+v.replace(/"/g,'""')+'"';};
  const csv=cols.map(c=>'"'+c.l+'"').join(',')+'\n'+rows.map(r=>cols.map(c=>cv(r,c)).join(',')).join('\n');
  const a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv'})); a.download='cre-mls-'+rows.length+'.csv'; a.click(); URL.revokeObjectURL(a.href); }
$('#csv').addEventListener('click',exportCSV);
// second CSV button in the results toolbar (same export as the header one)
if($('#csv2')) $('#csv2').addEventListener('click',exportCSV);
// ── draggable divider: drag to resize the filter rail; drag left to widen the records to the screen edge ──
(function(){
  const shell=document.querySelector('.shell'), div=$('#raildiv'); if(!shell||!div) return;
  const KEY='cre_mls_railw', DEF=260, MIN=0, MAX=560;
  const setW=w=>{ w=Math.max(MIN,Math.min(MAX,Math.round(w))); shell.style.setProperty('--rail-w',w+'px'); return w; };
  const cur=()=>{ const n=parseInt(getComputedStyle(shell).getPropertyValue('--rail-w')); return isNaN(n)?DEF:n; };
  const saved=parseInt(localStorage.getItem(KEY)); if(!isNaN(saved)) setW(saved);
  let dragging=false, startX=0, startW=DEF, lastW=DEF;
  div.addEventListener('mousedown',e=>{ dragging=true; startX=e.clientX; startW=cur(); div.classList.add('dragging'); document.body.classList.add('raildrag'); e.preventDefault(); });
  window.addEventListener('mousemove',e=>{ if(dragging) setW(startW+(e.clientX-startX)); });
  window.addEventListener('mouseup',()=>{ if(!dragging) return; dragging=false; div.classList.remove('dragging'); document.body.classList.remove('raildrag'); try{localStorage.setItem(KEY,String(cur()));}catch(e){} });
  div.addEventListener('dblclick',()=>{ const c=cur(); if(c>10){ lastW=c; setW(0); } else setW(lastW>10?lastW:DEF); try{localStorage.setItem(KEY,String(cur()));}catch(e){} });
})();
// ── right-edge grip: drag LEFT to widen the records/columns past the screen edge (table scrolls);
//    drag RIGHT to shrink back to fit; double-click resets to fill. Persisted. ──
(function(){
  const shell=document.querySelector('.shell'), grip=$('#recgrip'), tw=document.querySelector('#tableView'); if(!shell||!grip||!tw) return;
  const KEY='cre_mls_recw', MULT=2;
  const contW=()=>tw.clientWidth||1;
  const curPx=()=>{ const n=parseInt(getComputedStyle(shell).getPropertyValue('--rec-w')); return isNaN(n)?contW():n; };
  const setPx=w=>{ const min=contW(), max=min*3; w=Math.max(min,Math.min(max,Math.round(w))); shell.style.setProperty('--rec-w', w<=min+2?'100%':(w+'px')); };
  const saved=parseInt(localStorage.getItem(KEY)); if(!isNaN(saved)&&saved>0) requestAnimationFrame(()=>setPx(saved));
  let drag=false,startX=0,startW=0;
  grip.addEventListener('mousedown',e=>{ drag=true; startX=e.clientX; startW=Math.max(contW(),curPx()); grip.classList.add('dragging'); document.body.classList.add('raildrag'); e.preventDefault(); });
  window.addEventListener('mousemove',e=>{ if(drag) setPx(startW + (startX-e.clientX)*MULT); });   // drag LEFT → wider
  window.addEventListener('mouseup',()=>{ if(!drag) return; drag=false; grip.classList.remove('dragging'); document.body.classList.remove('raildrag'); const w=curPx(); try{localStorage.setItem(KEY, w>contW()+2?String(w):'0');}catch(e){} });
  grip.addEventListener('dblclick',()=>{ shell.style.setProperty('--rec-w','100%'); try{localStorage.setItem(KEY,'0');}catch(e){} });
})();
// ── property panel: drag its LEFT edge to widen the right-docked panel (savable); double-click resets ──
(function(){
  const modal=$('#modal'), rsz=$('#modalRsz'); if(!modal||!rsz) return;
  const KEY='cre_mls_modalw';
  const saved=parseInt(localStorage.getItem(KEY)); if(!isNaN(saved)&&saved>0) modal.style.setProperty('--modal-w', saved+'px');
  const curW=()=>modal.getBoundingClientRect().width;
  let drag=false,startX=0,startW=0;
  rsz.addEventListener('mousedown',e=>{ drag=true; startX=e.clientX; startW=curW(); rsz.classList.add('on'); document.body.style.userSelect='none'; e.preventDefault(); });
  window.addEventListener('mousemove',e=>{ if(!drag) return; const w=Math.max(420, Math.min(Math.round(window.innerWidth*0.96), Math.round(startW+(startX-e.clientX)))); modal.style.setProperty('--modal-w', w+'px'); });
  window.addEventListener('mouseup',()=>{ if(!drag) return; drag=false; rsz.classList.remove('on'); document.body.style.userSelect=''; try{localStorage.setItem(KEY,String(Math.round(curW())));}catch(e){} });
  rsz.addEventListener('dblclick',()=>{ modal.style.removeProperty('--modal-w'); try{localStorage.removeItem(KEY);}catch(e){} });
})();
// ── per-user settings: sign in with your own un/pw → your view (columns, order, rail width, theme,
//    heat, sort) follows you across browsers; auto-saves on exit; shows "last saved". Layered on the
//    existing CRCP accounts system (scrypt-hashed passwords, session cookie). ──
(function(){
  const bar=$('#acctbar'); if(!bar) return;
  const SKEYS=/^mls|^cre_mls|^crcp-theme$/;   // the localStorage keys that make up a user's view
  const collect=()=>{ const o={}; for(let i=0;i<localStorage.length;i++){ const k=localStorage.key(i); if(SKEYS.test(k)) o[k]=localStorage.getItem(k); } return o; };
  const applyLocal=o=>{ Object.keys(o||{}).forEach(k=>{ try{localStorage.setItem(k,o[k]);}catch(e){} }); };
  const fmt=ts=>{ if(!ts) return 'never'; try{ return new Date(ts).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }catch(e){ return '—'; } };
  const api=(u,o)=>fetch(u,Object.assign({headers:{'Content-Type':'application/json'}},o||{})).then(r=>r.json().then(j=>r.ok?j:Promise.reject(j)));
  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){} }
  function render(){
    if(ME&&ME.email){
      bar.innerHTML='☁ <b style="color:var(--ink)">'+esc(ME.username||ME.email)+'</b>'
        +' <span class="mut" id="lastSaved">· last saved '+esc(fmt(SAVED_AT))+'</span>'
        +' <a class="csvbtn" id="saveNow" style="cursor:pointer;padding:4px 9px">Save now</a>'
        +' <a id="signOut" class="mut" style="cursor:pointer;text-decoration:underline">sign out</a>';
      $('#saveNow').onclick=()=>save(false);
      $('#signOut').onclick=async()=>{ try{await fetch('/auth/logout',{method:'POST'});}catch(e){} sessionStorage.removeItem('mls_settings_synced'); location.reload(); };
    } else {
      bar.innerHTML='<span class="mut">Save your view →</span>'
        +'<input id="liUser" placeholder="your name" autocomplete="username" style="'+inp+'">'
        +'<input id="liPass" type="password" placeholder="password" autocomplete="current-password" style="'+inp+'">'
        +'<button class="csvbtn" id="liBtn">Sign in</button>'
        +'<button class="csvbtn" id="regBtn" title="Create a new account with this name + password">Create account</button>'
        +'<span class="mut" id="liErr"></span>';
      const auth=async(url)=>{ const username=$('#liUser').value.trim(), password=$('#liPass').value; if(!username||!password){ $('#liErr').textContent='enter a name + password'; return; } $('#liErr').textContent='…'; try{ await api(url,{method:'POST',body:JSON.stringify({username,password,name:username})}); sessionStorage.removeItem('mls_settings_synced'); location.reload(); }catch(e){ $('#liErr').textContent=(e&&e.error)||'failed'; } };
      $('#liBtn').onclick=()=>auth('/auth/login'); $('#regBtn').onclick=()=>auth('/auth/register'); $('#liPass').onkeydown=e=>{ if(e.key==='Enter') auth('/auth/login'); };
    }
  }
  async function boot(){
    try{ ME=await api('/api/me'); }catch(e){ ME=null; }
    if(ME&&ME.email){
      try{ const r=await api('/api/settings'); SAVED_AT=r.savedAt;
        if(r.settings && !sessionStorage.getItem('mls_settings_synced')){
          // Apply the account's saved view IN-MEMORY (was location.reload → caused "data flashes ~1s
          // then blank body": the reload fired on every fresh tab for a logged-in user because
          // sessionStorage is tab-scoped, wiping the first paint, and looped forever where sessionStorage
          // doesn't persist). rehydrateView() re-reads the just-written keys into the live vars + repaints;
          // no return, so boot() falls through to render the account bar + arm save-on-exit.
          applyLocal(r.settings); sessionStorage.setItem('mls_settings_synced','1'); rehydrateView();
        }
      }catch(e){}
      sessionStorage.setItem('mls_settings_synced','1');   // load complete → arm save-on-exit (even with no prior saved view)
    }
    render();
    document.addEventListener('visibilitychange',()=>{ if(document.visibilityState==='hidden') saveBeacon(); });
    window.addEventListener('pagehide', saveBeacon);
    window.addEventListener('beforeunload', saveBeacon);
  }
  boot();
})();
$('#shortbtn').addEventListener('click',()=>{ F.shortlist=!F.shortlist; render(); });
$('#moreWrap').addEventListener('click',e=>{ if(e.target.closest('#showmore')){ _shown=Math.min(_shown+PAGE(), _rows.length); paintBody(); } });
$('#heat').value=heat; $('#heat').addEventListener('change',()=>{ heat=$('#heat').value; localStorage.setItem('cre_heat_mls',heat); render(); });
$('#view').addEventListener('click',e=>{ const b=e.target.closest('button'); if(!b) return;
  document.querySelectorAll('#view button').forEach(x=>x.classList.toggle('on',x===b)); view=b.dataset.v; localStorage.setItem('mlsView',view); $('#denswrap').style.display=(view==='grid')?'inline-flex':'none'; render(); });
// ── sort <select> + density slider (Steve rule: every grid gets sort + density) ──
(function(){
  const sel=$('#sortsel'); const dz=$('#density');
  // build sort options from COLS (skip the source link column)
  sel.innerHTML='<option value="first_seen">🕓 Newest (first seen)</option>'+COLS.filter(c=>c.t!=='link'&&c.k!=='first_seen').map(c=>`<option value="${c.k}">Sort: ${esc(c.l==='#'?'Default (rank)':c.l)}</option>`).join('');
  // restore persisted sort
  const sk=localStorage.getItem('mlsSortKey'); const sd=localStorage.getItem('mlsSortDir');
  if(sk && COLS.some(c=>c.k===sk)) sortKey=sk;
  if(sd==='1'||sd==='-1') sortDir=+sd;
  sel.value=sortKey;
  sel.addEventListener('change',()=>{ sortKey=sel.value; const c=COLS.find(x=>x.k===sortKey);
    sortDir=(c&&(c.t==='s'||c.t==='status'))?1:-1;   // text asc, numbers/money desc
    localStorage.setItem('mlsSortKey',sortKey); localStorage.setItem('mlsSortDir',sortDir); render(); });
  // density 1..10  →  card-min width (1 = big/sparse, 10 = small/dense)
  const widthFor=v=>Math.round(380-(Math.max(1,Math.min(10,v))-1)*(380-140)/9);
  function applyDensity(v){ document.documentElement.style.setProperty('--card-min', widthFor(v)+'px'); }
  const dv=parseInt(localStorage.getItem('mlsDensity'),10); const dval=(dv>=1&&dv<=10)?dv:5;
  dz.value=dval; applyDensity(dval);
  dz.addEventListener('input',()=>{ const v=+dz.value; applyDensity(v); localStorage.setItem('mlsDensity',v); });
  // restore persisted view (and show density only in grid view)
  const pv=localStorage.getItem('mlsView'); if(pv==='grid'||pv==='table'||pv==='list'){ view=pv; document.querySelectorAll('#view button').forEach(x=>x.classList.toggle('on',x.dataset.v===view)); }
  $('#denswrap').style.display=(view==='grid')?'inline-flex':'none';
  // keep the select in sync when the user sorts via a table-column header
  window.__syncSortSel=()=>{ if(sel.value!==sortKey) sel.value=sortKey; };
})();
// left-rail facets + column toggles
$('#fType').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.types,c.dataset.ty);c.classList.toggle('active');render();});
$('#fStatus').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.status,c.dataset.st);c.classList.toggle('active');render();});
$('#fCity').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.cities,c.dataset.cy);c.classList.toggle('active');render();});
$('#fSource').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.source,c.dataset.so);c.classList.toggle('active');render();});
$('#fFirm').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.firms,c.dataset.fm);c.classList.toggle('active');render();});
// ── numeric range filters are STAGED — type the values, then hit Go (or press Enter) to apply ──
const STAGE={pmin:null,pmax:null,umin:null,umax:null,ymin:null,ymax:null};
const NUMFIELDS=[['pMin','pmin'],['pMax','pmax'],['uMin','umin'],['uMax','umax'],['yMin','ymin'],['yMax','ymax']];
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; F.unwarrantable=F.nonqm=false;
  const got=[]; let m;
  // CRCP condo warrantability terms
  if(/unwarrantable|non-?warrantable|not warrantable/.test(s)){ F.unwarrantable=true; got.push('unwarrantable condos'); }
  if(/non-?qm\b|non qm/.test(s)){ F.nonqm=true; got.push('non-QM'); }
  if(/\bcondos?\b/.test(s) && !F.unwarrantable){ F.types.add('Condo'); got.push('Condo'); }
  // 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'; });
  const ex=document.getElementById('chatex');
  if(ex) ex.addEventListener('click',e=>{ const c=e.target.closest('.chatchip'); if(!c)return; q.value=c.dataset.q; applyChat(c.dataset.q); });
})();
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(); } });
});
if($('#applyBtn')) $('#applyBtn').addEventListener('click',applyStaged);
if($('#clearNum')) $('#clearNum').addEventListener('click',()=>{ NUMFIELDS.forEach(([id,k])=>{ const el=$('#'+id); if(el) el.value=''; STAGE[k]=null; F[k]=null; }); markStaged(); render(); });
// (#fFields change is handled inside column-manager.js now — its onChange calls render)
// ── panel drag-to-reorder columns (Steve rule: resort order here in the panel too) ──
let pDragKey=null;
$('#fFields').addEventListener('dragstart',e=>{ const row=e.target.closest('.ftog'); if(!row) return;
  if(!e.target.closest('.fdrag')){ e.preventDefault(); return; }   // only the ⠿ handle starts a move
  pDragKey=row.dataset.fk; e.dataTransfer.effectAllowed='move'; try{e.dataTransfer.setData('text/plain',pDragKey);}catch(_){}
  row.classList.add('fdragging'); });
$('#fFields').addEventListener('dragover',e=>{ if(!pDragKey) return; const row=e.target.closest('.ftog'); if(!row||row.dataset.fk===pDragKey) return;
  e.preventDefault(); e.dataTransfer.dropEffect='move';
  const r=row.getBoundingClientRect(), after=(e.clientY-r.top)>r.height/2;
  $('#fFields').querySelectorAll('.ftog').forEach(x=>x.classList.remove('fdropT','fdropB'));
  row.classList.toggle('fdropB',after); row.classList.toggle('fdropT',!after); });
$('#fFields').addEventListener('dragleave',e=>{ const row=e.target.closest('.ftog'); if(row) row.classList.remove('fdropT','fdropB'); });
$('#fFields').addEventListener('drop',e=>{ if(!pDragKey) return; const row=e.target.closest('.ftog'); if(!row){ pDragKey=null; return; } e.preventDefault();
  const tgt=row.dataset.fk;
  if(tgt!==pDragKey){
    const r=row.getBoundingClientRect(), after=(e.clientY-r.top)>r.height/2;
    syncColOrder(); const from=COLORDER.indexOf(pDragKey);
    if(from>=0){ COLORDER.splice(from,1); let to=COLORDER.indexOf(tgt); if(after) to+=1; COLORDER.splice(to,0,pDragKey);
      saveColOrder(); lastColSig=''; buildFieldToggles(); render(); }
  }
  pDragKey=null; });
$('#fFields').addEventListener('dragend',()=>{ $('#fFields').querySelectorAll('.fdragging,.fdropT,.fdropB').forEach(x=>x.classList.remove('fdragging','fdropT','fdropB')); pDragKey=null; });
$('#fAll').addEventListener('click',()=>{COLS.forEach(c=>VISCOL[c.k]=true);saveCols();buildFieldToggles();render();});
$('#fNone').addEventListener('click',()=>{COLS.forEach(c=>VISCOL[c.k]=false);saveCols();buildFieldToggles();render();});
// Reset the whole table layout back to the tight default: visibility, order, widths.
$('#fReset').addEventListener('click',()=>{ VISCOL={}; saveCols(); COLORDER=[]; saveColOrder();
  try{Object.keys(localStorage).forEach(k=>{ if(k.indexOf('cr:'+location.pathname+':')===0) localStorage.removeItem(k); });}catch(e){}
  lastColSig=''; buildFieldToggles(); render(); });
const unwEl=document.getElementById('unwChip');
if(unwEl) unwEl.addEventListener('click',()=>{ F.unwarrantable=!F.unwarrantable; unwEl.classList.toggle('active',F.unwarrantable); if(F.unwarrantable){ F.types=new Set(['Condo']); document.querySelectorAll('#fType .chip').forEach(x=>x.classList.toggle('active',x.dataset.ty==='Condo')); } render(); });
const nqEl=document.getElementById('nqChip');
if(nqEl) nqEl.addEventListener('click',()=>{ F.nonqm=!F.nonqm; nqEl.classList.toggle('active',F.nonqm); render(); });
// Just Listed / Just Sold quick-filters + adjustable day window (persisted)
const listedEl=document.getElementById('listedChip'), soldEl=document.getElementById('soldChip'), rdEl=document.getElementById('recentDays');
if(rdEl){ rdEl.value=F.recentDays; rdEl.addEventListener('change',()=>{ const v=parseInt(rdEl.value,10); if(v>0&&v<=365){ F.recentDays=v; localStorage.setItem('mlsRecentDays',v); if(F.justsold||F.justlisted) render(); } }); }
const escrowEl=document.getElementById('escrowChip'), fellEl=document.getElementById('fellChip'), cutEl=document.getElementById('cutChip');
const quickChips=[['justlisted',listedEl],['justsold',soldEl],['inescrow',escrowEl],['fellout',fellEl],['pricecut',cutEl]];
function setQuick(key,el){ const on=!F[key]; quickChips.forEach(([k,e])=>{ F[k]=false; e&&e.classList.remove('active'); }); F[key]=on; if(el) el.classList.toggle('active',on); render(); }
if(listedEl) listedEl.addEventListener('click',()=>setQuick('justlisted',listedEl));
if(soldEl) soldEl.addEventListener('click',()=>setQuick('justsold',soldEl));
if(escrowEl) escrowEl.addEventListener('click',()=>setQuick('inescrow',escrowEl));
if(fellEl) fellEl.addEventListener('click',()=>setQuick('fellout',fellEl));
if(cutEl) cutEl.addEventListener('click',()=>setQuick('pricecut',cutEl));
// collapsible rail tabs — all collapsed on load
const railEl=document.querySelector('.rail');
if(railEl){ railEl.addEventListener('click',e=>{ const h=e.target.closest('h4'); if(h && h.parentElement.classList.contains('rsec')) h.parentElement.classList.toggle('collapsed'); });
  document.querySelectorAll('.rail .rsec').forEach(s=>{ if(s.id!=='fieldsSec') s.classList.add('collapsed'); }); }
fetch('/data/ranked.json').then(r=>r.json()).then(async d=>{ DATA=(d.ranked||d)||[];
  // Residential inventory bolted onto the CRE table (asset-class chips auto-build from r.type):
  try{ const cd=await (await fetch('/api/condos')).json(); DATA=DATA.concat((cd.condos||[]).map(mapCondo)); }catch(e){}
  try{ const sr=await (await fetch('/api/residential')).json(); DATA=DATA.concat((sr.sfr||[]).map(mapSfr)); }catch(e){}
  // SOURCING DOCTRINE (docs/SOURCING.md): the listing broker-of-record is the truth; the aggregator
  // is a breadcrumb. Display the REAL broker firm (not "Crexi"), and classify residential rows the
  // same way CRE deals are classified in enrich-provenance.js.
  (function applyDoctrine(){
    const AGG=['crexi','redfin','zillow','costar','loopnet','realtor','myelisting'];
    const hostAgg=u=>{ try{ const h=new URL(u).hostname.replace(/^www\./,''); return AGG.find(a=>h.includes(a))||null; }catch(e){ return null; } };
    DATA.forEach(r=>{
      if(r.broker_firm && !/^crexi$/i.test(r.broker_firm)) r.firm=r.broker_firm;   // show the real brokerage
      if(!r.broker_agent && r.broker_name) r.broker_agent=r.broker_name;            // residential agent
      if(!r.source_of_truth){                                                       // classify residential
        const agg=hostAgg(r.source);
        r.source_of_truth = !agg && r.source ? 'firm-direct' : (r.firm ? 'broker-of-record' : 'aggregator');
      }
    });
  })();
  // Rent averages: join Census ACS median gross rent by city (area proxy — NOT the specific home's
  // achievable rent). Powers Area Rent/mo, Area GRY % (gross rental yield), and area rent stat.
  try{ const dm=await (await fetch('/data/demographics.json')).json(); const bc=dm.byCity||{};
    const RENT={}; Object.keys(bc).forEach(k=>{ RENT[k.toLowerCase().trim()]=bc[k]; });
    DATA.forEach(r=>{ const e=RENT[String(r.city||'').toLowerCase().trim()]; if(!e) return;
      r.area_rent=e.median_gross_rent||null;
      r.renter_pct=e.renter_pct||null; r.rent_burden_pct=e.rent_burden_pct||null;
      if(r.area_rent&&+r.price>0) r.area_yield=+((r.area_rent*12/+r.price)*100).toFixed(2);
    });
  }catch(e){}
  // Last Broker/Firm of Record (TK-10139): merge the newest broker-of-record history observation per
  // address from the static map baked by scripts/backfill-broker-of-record.js. address_key normalization
  // MUST match the recorder: lower(trim(collapse-ws( address + ", " + city ))).
  try{ const bor=await (await fetch('/data/last-broker-of-record.json')).json(); const MAP=(bor&&bor.by_address_key)||{};
    const akey=(a,c)=>{ const A=String(a||'').trim(), C=String(c||'').trim(); return (C?A+', '+C:A).toLowerCase().replace(/\s+/g,' ').trim(); };
    DATA.forEach(r=>{ const e=MAP[akey(r.address,r.city)]; if(!e) return;
      r.last_broker_of_record=e.last_broker_of_record||null;   // agent = the broker of record
      r.last_firm_of_record=e.last_firm_of_record||null;        // firm  = the firm of record
      r.last_bor_observed_at=e.observed_at||null;
    });
  }catch(e){}
  // Agent OWN-site + phone (bounded $0 discovery, /api/agent-sites keyed norm(agent)|norm(firm)).
  try{ const as=await (await fetch('/api/agent-sites')).json(); const SITES=(as&&as.sites)||{};
    const nrm=s=>String(s||'').toLowerCase().replace(/[^a-z0-9]+/g,' ').trim();
    DATA.forEach(r=>{ const e=SITES[nrm(r.broker_agent)+'|'+nrm(r.broker_firm)]; if(!e) return;
      if(e.website) r.agent_site=e.website;
      if(e.phone) r.agent_phone=e.phone;
      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();
    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'; });

// $0 on-demand: click "🔎 agent" → discover that one agent's own site+phone (local, free).
document.addEventListener('click', async e=>{
  const b=e.target.closest('.agent-find'); if(!b) return;
  e.preventDefault(); b.disabled=true; const t=b.textContent; b.textContent='🔎 …';
  try{
    const r=await (await fetch('/api/agent/discover',{method:'POST',headers:{'Content-Type':'application/json'},
      body:JSON.stringify({agent:b.dataset.a,firm:b.dataset.f,city:b.dataset.c})})).json();
    if(r.status==='found'&&r.website){
      // Write the discovery back onto the matching row object(s) — not just the DOM — so the agent's
      // site/phone become searchable and survive a re-render. Invalidate _hay so the cached search
      // haystack rebuilds with the new fields. (Cody Pass-3 gate: was DOM-only, silently unsearchable.)
      const _n=s=>String(s||'').toLowerCase().replace(/[^a-z0-9]+/g,' ').trim();
      const na=_n(b.dataset.a), nf=_n(b.dataset.f);
      DATA.forEach(row=>{ if(_n(row.broker_agent)===na && _n(row.broker_firm)===nf){ row.agent_site=r.website; if(r.phone) row.agent_phone=r.phone; row._hay=null; } });
      const bits=[]; if(r.phone) bits.push(`<a href="tel:${esc(r.phone)}">📞 ${esc(r.phone)}</a>`);
      bits.push(`<a href="${esc(r.website)}" target="_blank" rel="noopener noreferrer">👤 agent ↗</a>`);
      b.outerHTML=bits.join(' ');
    } else if(r.status==='throttled'){ b.disabled=false; b.textContent='🔎 busy — retry'; }
    else b.outerHTML='<span style="opacity:.6;font-size:11px">👤 no site</span>';
  }catch(_){ b.disabled=false; b.textContent=t; }
});
</script>
  <script src="/column-manager.js" defer></script>
  <script src="/col-resize.js" defer></script>
  <script src="/global-search.js" defer></script>
  <script src="/sort-bar.js" defer></script>
<script src="/corner-nav.js" defer></script>
<script src="/crcp-theme.js" defer></script>
  <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
</body>
</html>