← back to Commercialrealestate

public/sales.html

479 lines

<!doctype html>
<html lang="en">
<head>
<script>(function(){try{var t=localStorage.getItem('crcp-theme');document.documentElement.setAttribute('data-theme',t==='light'?'light':'dark');}catch(e){document.documentElement.setAttribute('data-theme','dark');}})();</script>
<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>
<!-- Self-heal: nav-agent grid controls can persist a "removed"/"collapsed" state that hides the
     whole grid with no obvious way back. Clear it before nav-agent runs so a grid can never stay hidden. -->
<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='%230a0d13'/%3E%3Ctext x='8' y='12' font-size='11' text-anchor='middle' fill='%23c8a24b'%3E%24%3C/text%3E%3C/svg%3E">
<title>Closed Sales — LA Condo Comps</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>
  :root{ --bg:#0c0f15; --card:#141925; --line:#232a38; --ink:#E6E8EC; --mut:#8A93A2; --gold:#C8A24B; --acc:#70AD47; --blue:#58a6ff; --warn:#d29922; }
  *{box-sizing:border-box;} body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",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:rgba(12,15,21,.98);backdrop-filter:blur(8px);flex-wrap:wrap;}
  header h1{font-size:16px;margin:0;} header h1 b{color:var(--gold);}
  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(--gold);}
  .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(--gold);color:#1a1205;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;}
  .stat{color:var(--mut);font-size:13px;padding:12px 22px 4px;} .stat b{color:var(--ink);}
  #depthNote{font-size:12px;color:var(--mut);margin:2px 22px 8px;padding:6px 10px;border-left:2px solid var(--gold);background:rgba(200,162,75,.06);}
  #depthNote:empty{display:none;}
  .wrap{padding:24px 28px 60px;}
  .tblwrap{overflow:auto;max-height:calc(100vh - 210px);border:1px solid var(--line);border-radius:10px;}
  table.g{border-collapse:separate;border-spacing:0;width:100%;font-size:12.5px;white-space:nowrap;}
  table.g thead th{position:sticky;top:0;background:#1b2230;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.g thead th:hover{color:var(--ink);}
  table.g thead th.asc::after{content:' ▲';color:var(--gold);} table.g thead th.desc::after{content:' ▼';color:var(--gold);}
  /* draggable column reorder (Steve rule: move columns around) */
  table.g thead th.dragcol{cursor:grab;}
  table.g thead th.dragging{opacity:.45;}
  table.g thead th.dropL{box-shadow:inset 3px 0 0 var(--gold);}
  table.g thead th.dropR{box-shadow:inset -3px 0 0 var(--gold);}
  table.g tbody td{padding:7px 10px;border-bottom:1px solid #20262f;}
  table.g tbody tr:hover td{background:rgba(200,162,75,.07);}
  table.g td.n{text-align:right;font-variant-numeric:tabular-nums;}
  table.g a{color:var(--blue);text-decoration:none;} table.g a:hover{text-decoration:underline;}
  .price{color:var(--acc);font-weight:600;}
  .mut{color:var(--mut);} .miss{color:#5A6472;}
  #ov{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:60;align-items:flex-start;justify-content:center;padding:40px 14px;overflow-y:auto;}
  #ov.on{display:flex;}
  #modal{width:min(640px,96vw);background:var(--card);border:1px solid var(--line);border-radius:15px;padding:20px 22px;position:relative;}
  #modal h3{margin:0 0 2px;font-size:20px;} #modal .msub{color:var(--gold);font-size:13px;}
  #modal .x{position:absolute;top:12px;right:14px;background:none;border:0;color:var(--mut);font-size:22px;cursor:pointer;}
  #modal .sec{margin-top:14px;} #modal .sec h4{margin:0 0 6px;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);}
  #modal .litem{display:flex;justify-content:space-between;gap:10px;padding:5px 0;border-bottom:1px solid #20262f;font-size:13px;} #modal .litem .m{color:var(--mut);}
  #modal .ci{display:flex;gap:16px;flex-wrap:wrap;margin:10px 0;font-size:14px;} #modal .ci a{color:var(--blue);text-decoration:none;}
  .charts{display:grid;grid-template-columns:1fr 1fr;gap:16px;padding:18px 28px;} @media(max-width:880px){.charts{grid-template-columns:1fr;}}
  .panel{background:var(--card);border:1px solid var(--line);border-radius:13px;padding:16px;}
  .panel h3{margin:0 0 12px;font-size:12px;text-transform:uppercase;letter-spacing:.6px;color:var(--gold);}
  canvas{max-height:240px;}
  .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--card-min,280px),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:2px solid #fff;border-radius:11px;padding:18px 20px;cursor:pointer;}
  .gc .nm{font-weight:700;} .gc .fm{color:var(--mut);font-size:12px;margin-bottom:6px;}
  .gc .row{display:flex;justify-content:space-between;gap:8px;font-size:12.5px;padding:2px 0;border-top:1px solid #20262f;}
  .gc .row .k{color:var(--mut);}
  .hide{display:none;}
  /* ---- Amazon-style left rail ---- */
  .shell{display:flex;align-items:flex-start;}
  .rail{flex:0 0 260px;background:#0a0d13;border-right:1px solid var(--line);align-self:stretch;position:sticky;top:53px;height:calc(100vh - 53px);overflow-y:auto;padding:0 0 40px;}
  .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(--gold);color:var(--gold);background:rgba(200,162,75,.10);}
  .chip .ct{color:var(--mut);font-size:10px;margin-left:4px;}
  .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(200,162,75,.08);} .ftog input{accent-color:var(--gold);}
  .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(--gold);} .ftog.fdropB{box-shadow:inset 0 -3px 0 var(--gold);}
  .fgrp{font-size:9.5px;text-transform:uppercase;letter-spacing:.6px;color:var(--gold);margin:9px 0 3px;font-weight:700;opacity:.85;} .fgrp:first-child{margin-top:0;}
  .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(--gold);color:var(--ink);}
  .wrap{flex:1 1 auto;min-width:0;}
  @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; }
</style>
  <style>
  /* compact List view (3rd view) */
  .lst{padding:14px 20px 0;display:flex;flex-direction:column;}
  .lrow{display:flex;align-items:baseline;gap:16px;flex-wrap:wrap;padding:10px 4px;border-bottom:1px solid var(--line2,#20262f);cursor:pointer;}
  .lrow:hover{background:rgba(200,162,75,.06);}
  .lnm{font-weight:700;min-width:190px;flex:0 0 auto;color:var(--ink);}
  .lmeta{display:flex;flex-wrap:wrap;gap:5px 16px;flex:1 1 320px;min-width:0;}
  .lm{font-size:12.5px;white-space:nowrap;}
  .lk{color:var(--mut);font-size:10px;text-transform:uppercase;letter-spacing:.5px;margin-right:5px;}
  /* mobile: ⚙ Filters button + rail modal (rail is display:none <820px) */
  .railbtn{display:none;background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:9px;padding:8px 12px;font-size:13px;cursor:pointer;}
  .railbtn:hover{border-color:var(--gold);}
  .railov{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:70;display:none;align-items:flex-start;justify-content:center;padding:14px;}
  .railov.on{display:flex;}
  .railmd{width:100%;max-width:520px;max-height:90vh;display:flex;flex-direction:column;background:var(--card);border:1px solid var(--line);border-radius:15px;overflow:hidden;}
  .railmd-h{display:flex;align-items:center;justify-content:space-between;padding:13px 16px;border-bottom:1px solid var(--line);font-size:15px;color:var(--ink);}
  .railmd-x{background:none;border:0;color:var(--mut);font-size:20px;cursor:pointer;line-height:1;}
  .railmd-b{overflow:auto;padding:0;}
  .rail.in-modal{display:block!important;position:static!important;height:auto!important;width:auto!important;flex:none!important;top:auto!important;border:0!important;}
  @media(max-width:820px){ .railbtn{display:inline-block;} }
  </style>
  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
  <link rel="stylesheet" href="/crcp-theme.css"><!-- CRCP day/night tokens: load LAST so html[data-theme] wins -->
</head>
<body>
<header>
  <h1><b>💲 Closed Sales</b> · LA condo comps</h1>
  <div class="nav"><a href="/crcp.html">← CRCP</a><a href="/condos.html">Condos</a><a href="/graphics.html">Graphics</a><a href="/fha-loans.html">FHA Loans</a></div>
  <span class="spacer"></span>
  <input class="search" id="q" type="text" placeholder="search all fields — address, city, zip, price, beds, sqft… (space = AND)">
  <button class="railbtn" id="railBtn" type="button">⚙ Filters</button>
  <div class="toggle" 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 sold comps"></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="csv" title="Export current rows to CSV">⬇ CSV</button>
  <span class="count" id="count">…</span>
</header>
<div class="stat" id="stat">loading…</div>
<div id="depthNote"></div>
<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 List view you can also drag a column header to reorder · its right edge to resize · click to sort.</div>
  </div>
  <div class="rsec"><h4>City</h4><div class="chips" id="fCity"></div></div>
  <div class="rsec"><h4>Sold year</h4><div class="chips" id="fYear"></div></div>
  <div class="rsec"><h4>Beds</h4><div class="chips" id="fBeds"></div></div>
</aside>
<div class="wrap">
  <div id="tableView" class="tblwrap"><table class="g" data-no-sort><thead id="thead"></thead><tbody id="tbody"></tbody></table></div>
  <div id="gridView" class="grid hide"></div>
  <div id="listView" class="lst hide"></div>
  <div class="charts">
    <div class="panel"><h3>By city (avg price)</h3><canvas id="cityChart"></canvas></div>
    <div class="panel"><h3>Sales by year</h3><canvas id="yearChart"></canvas></div>
  </div>
</div>
</div>
<div id="ov"><div id="modal"><button class="x" aria-label="Close" onclick="document.getElementById('ov').classList.remove('on')">✕</button><div id="mbody"></div></div></div>
<script>
const $=s=>document.querySelector(s);
const esc=s=>(s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;');
const num=n=>(n==null||n==='')?'—':Number(n).toLocaleString();
const money=n=>(n==null||n==='')?'—':'$'+Number(n).toLocaleString();
const url=(v,lbl)=>{ if(!v) return '<span class="miss">—</span>'; const h=/^https?:/.test(v)?v:'https://'+v; return `<a href="${esc(h)}" target="_blank" rel="noopener noreferrer">${lbl}</a>`; };
const dt=s=>{ if(!s) return '—'; try{ return new Date(s).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}); }catch(e){ return '—'; } };
const ppsf=r=>r.sqft?Math.round(r.sold_price/r.sqft):null;
// Every field carries a group `g` (sort menu optgroups + organized rail toggles).
// Order below IS the column order. Every field is sortable (raw-value sort, any type).
const COLS=[
  // ── Property ──
  {k:'address',l:'Address',t:'s',g:'Property'},
  {k:'city',l:'City',t:'s',g:'Property'},
  {k:'zip',l:'ZIP',t:'s',g:'Property',def:0},
  // ── Sale ──
  {k:'sold_price',l:'Sold',t:'money',g:'Sale'},
  {k:'sold_date',l:'Sold Date',t:'date',g:'Sale',get:r=>String(r.sold_date||'').slice(0,10)},
  {k:'ppsf',l:'$/Sq Ft',t:'money',g:'Sale',get:ppsf},
  // ── Unit ──
  {k:'beds',l:'Beds',t:'n',g:'Unit'},
  {k:'baths',l:'Baths',t:'n',g:'Unit'},
  {k:'sqft',l:'Sq Ft',t:'n',g:'Unit'},
  // ── Details ──
  {k:'url',l:'Listing',t:'link',g:'Details',def:0},
];
const val=(r,c)=>c.get?c.get(r):r[c.k];
let DATA=[], view='table', sortKey='sold_date', sortDir=-1, q='', lastColSig='';
const F={city:new Set(),year:new Set(),beds:new Set()};
// START TIGHT (Steve rule): 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','sold_price','sold_date','beds','baths','sqft']);
let VISCOL=(function(){let s={};try{s=JSON.parse(localStorage.getItem('salesCols')||'{}');}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('salesColOrder')||'[]');}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)); }
function saveCols(){ try{localStorage.setItem('salesCols',JSON.stringify(VISCOL));}catch(e){} }
function saveColOrder(){ try{localStorage.setItem('salesColOrder',JSON.stringify(COLORDER));}catch(e){} }
// 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.g'); 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); }
function autoCols(){
  const known=new Set(COLS.map(c=>c.k)); const skip=new Set(['id']);
  const seen=new Set();
  DATA.slice(0,60).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', g:'Other', def:0});
  }));
}
function buildRail(){
  const cc={},yc={},bc={};
  DATA.forEach(r=>{
    if(r.city)cc[r.city]=(cc[r.city]||0)+1;
    const yr=String(r.sold_date||'').slice(0,4); if(yr)yc[yr]=(yc[yr]||0)+1;
    const bd=(r.beds==null||r.beds==='')?'—':String(r.beds); bc[bd]=(bc[bd]||0)+1;
  });
  $('#fCity').innerHTML=Object.entries(cc).sort((a,b)=>b[1]-a[1]).slice(0,20).map(([k,c])=>`<span class="chip" data-ci="${esc(k)}">${esc(k)}<span class="ct">${c}</span></span>`).join('');
  $('#fYear').innerHTML=Object.entries(yc).sort((a,b)=>b[0]<a[0]?-1:1).map(([k,c])=>`<span class="chip" data-yr="${esc(k)}">${esc(k)}<span class="ct">${c}</span></span>`).join('');
  $('#fBeds').innerHTML=Object.entries(bc).sort((a,b)=>b[1]-a[1]).map(([k,c])=>`<span class="chip" data-bd="${esc(k)}">${esc(k)}<span class="ct">${c}</span></span>`).join('');
  buildFieldToggles();
}
// Column show/hide + in-panel search & drag-reorder via the shared column-manager.js
// drop-in (TK-10120), as a VIEW over this page's own VISCOL/COLORDER state.
let __cm=null;
function buildFieldToggles(){
  if(!window.ColumnManager){ // fallback if the module hasn't loaded (keeps page usable)
    // emit rows in COLORDER order with draggable ⠿ handle (mirrors condos.html panel drag)
    syncColOrder();
    $('#fFields').innerHTML=COLORDER.map(k=>COLS.find(c=>c.k===k)).filter(Boolean).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 cols=COLS.map(c=>({k:c.k,l:c.l,g:c.g||'Other'}));
  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 passFacets(r){
  if(F.city.size && !F.city.has(r.city)) return false;
  if(F.year.size && !F.year.has(String(r.sold_date||'').slice(0,4))) return false;
  if(F.beds.size){ const bd=(r.beds==null||r.beds==='')?'—':String(r.beds); if(!F.beds.has(bd)) return false; }
  return true;
}
function cell(r,c){
  const v=val(r,c);
  if(c.k==='address') return esc(v==null?'—':v);
  if(c.t==='money') return v==null||v===''?'<span class="miss">—</span>':`<span class="price">${money(v)}</span>`;
  if(c.t==='n') return num(v);
  if(c.t==='date') return dt(v);
  if(c.t==='link') return url(v,'view↗');
  return esc(v==null||v===''?'—':v);
}
function filtered(){
  let rows=DATA.filter(passFacets);
  if(q){
    // Search ALL data (Steve rule): 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=Object.keys(r).filter(k=>k!=='__hay').map(k=>{const v=r[k];return v==null?'':(typeof v==='object'?JSON.stringify(v):String(v));}).join('  ').toLowerCase();
      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=val(a,c), y=val(b,c);
    const xn=(x!=null&&x!==''&&!isNaN(+x)), yn=(y!=null&&y!==''&&!isNaN(+y));
    if(xn&&yn){ x=+x; y=+y; } else { x=String(x==null?'':x).toLowerCase(); y=String(y==null?'':y).toLowerCase(); }
    if(x<y) return -sortDir; if(x>y) return sortDir; return 0;
  });
  return rows;
}
function render(){
  const rows=filtered(); const cols=visCols();
  $('#count').textContent=`${rows.length} of ${DATA.length} sold comps`;
  $('#thead').innerHTML='<tr>'+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>';
  const lv=$('#listView');
  $('#tableView').classList.toggle('hide',view!=='table');
  $('#gridView').classList.toggle('hide',view!=='grid');
  if(lv) lv.classList.toggle('hide',view!=='list');
  if(view==='table'){
    $('#tbody').innerHTML=rows.map((r,i)=>`<tr class="srow" data-i="${i}" style="cursor:pointer">`+cols.map(c=>{const cls=(c.t==='n'||c.t==='money')?' class="n"':'';return `<td${cls}>${cell(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.g')); } // resize handles survive sort/search re-render (TK-10089)
  } else if(view==='list' && lv){
    lv.innerHTML=rows.map((r,i)=>`<div class="lrow srow" data-i="${i}" style="cursor:pointer"><div class="lnm">${esc(r.address||'—')}</div><div class="lmeta">`+cols.filter(c=>c.k!=='address').map(c=>`<span class="lm"><span class="lk">${esc(c.l)}</span>${cell(r,c)}</span>`).join('')+`</div></div>`).join('');
  } else {
    const skipGrid=new Set(['address','city']);
    const bodyCols=cols.filter(c=>!skipGrid.has(c.k));
    $('#gridView').innerHTML=rows.map((r,i)=>`<div class="gc srow" data-i="${i}">
      <div class="nm">${esc(r.address||'—')}</div>
      <div class="fm">${esc([r.city,r.zip].filter(Boolean).join(' ')||'—')}</div>`
      +bodyCols.map(c=>`<div class="row"><span class="k">${esc(c.l)}</span><span>${cell(r,c)}</span></div>`).join('')
    +`</div>`).join('');
  }
  // keep the filtered set for the modal (expandable rows)
  render._rows=rows;
}
function openDetail(i){
  const rows=render._rows||filtered(); const r=rows[i]; if(!r) return;
  const ov=$('#ov'), mb=$('#mbody'); ov.classList.add('on');
  const w=r.url?(/^https?:/.test(r.url)?r.url:'https://'+r.url):null;
  mb.innerHTML='<h3>'+(function(){const q=[r.address,r.city,r.zip].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(r.address||'—')+' 🗺️</a>':esc(r.address||'—');})()+'</h3><div class="msub">'+esc([r.city,r.zip].filter(Boolean).join(' ')||'—')+'</div>'
    +'<div class="ci">'
    +'<span class="price" style="font-size:18px">'+money(r.sold_price)+'</span>'
    +'<span class="mut">sold '+dt(r.sold_date)+'</span>'
    +(w?'<a href="'+esc(w)+'" target="_blank" rel="noopener noreferrer">🌐 listing↗</a>':'')
    +'</div>'
    +'<div class="sec"><h4>All fields</h4>'
    +COLS.map(c=>{ const raw=val(r,c); return '<div class="litem"><span class="m">'+esc(c.l)+'</span><span>'+(c.t==='link'?(w?'<a href="'+esc(w)+'" target="_blank" rel="noopener noreferrer">view↗</a>':'—'):cell(r,c))+'</span></div>'; }).join('')
    +Object.keys(r).filter(k=>!COLS.some(c=>c.k===k)).map(k=>{ const v=r[k]; if(v==null||typeof v==='object') return ''; return '<div class="litem"><span class="m">'+esc(k)+'</span><span>'+esc(v)+'</span></div>'; }).join('')
    +'</div>';
}
document.addEventListener('click',e=>{ const row=e.target.closest('.srow'); if(row && e.target.tagName!=='A'){ openDetail(+row.dataset.i); } if(e.target.id==='ov') $('#ov').classList.remove('on'); });
document.addEventListener('keydown',e=>{ if(e.key==='Escape') $('#ov').classList.remove('on'); });
$('#thead').addEventListener('click',e=>{const th=e.target.closest('th');if(!th)return;const k=th.dataset.k;
  if(sortKey===k)sortDir*=-1;else{sortKey=k;const c=COLS.find(x=>x.k===k);sortDir=(c&&(c.t==='n'||c.t==='money'||c.t==='date'))?-1:1;}
  localStorage.setItem('salesSortKey',sortKey);localStorage.setItem('salesSortDir',sortDir);if(window.__syncSortSel)window.__syncSortSel();render();});
// ── drag-to-reorder columns (Steve rule: move columns around) ──
let dragKey=null;
$('#thead').addEventListener('dragstart',e=>{ const th=e.target.closest('th'); if(!th) 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===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) 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; });
$('#q').addEventListener('input',()=>{q=$('#q').value.trim();render();});
function exportCSV(){ const rows=filtered(); const cols=visCols(); const cv=(r,c)=>{let v=val(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-closed-sales-'+rows.length+'.csv'; a.click(); URL.revokeObjectURL(a.href); }
$('#csv').addEventListener('click',exportCSV);
$('#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('salesView',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'),dz=$('#density');
  // Grouped sort menu — EVERY field is sortable, organized under its category optgroup.
  // Rebuildable so auto-discovered columns join the menu once data loads.
  window.__buildSortSel=()=>{
    const groups={};
    COLS.forEach(c=>{ (groups[c.g||'Other']=groups[c.g||'Other']||[]).push(c); });
    sel.innerHTML=Object.entries(groups).map(([g,cs])=>
      `<optgroup label="${esc(g)}">`+cs.map(c=>`<option value="${c.k}">${esc(c.l)}</option>`).join('')+`</optgroup>`).join('');
    if(Array.from(sel.options).some(o=>o.value===sortKey)) sel.value=sortKey;
  };
  window.__buildSortSel();
  const sk=localStorage.getItem('salesSortKey'),sd=localStorage.getItem('salesSortDir');
  if(sk&&COLS.some(c=>c.k===sk))sortKey=sk; if(sd==='1'||sd==='-1')sortDir=+sd;
  if(!Array.from(sel.options).some(o=>o.value===sortKey))sortKey='sold_date';
  sel.value=sortKey;
  sel.addEventListener('change',()=>{sortKey=sel.value;const c=COLS.find(x=>x.k===sortKey);
    sortDir=(c&&(c.t==='n'||c.t==='money'||c.t==='date'))?-1:1;
    localStorage.setItem('salesSortKey',sortKey);localStorage.setItem('salesSortDir',sortDir);render();});
  const widthFor=v=>Math.round(400-(Math.max(1,Math.min(10,v))-1)*(400-150)/9);
  const applyDensity=v=>document.documentElement.style.setProperty('--card-min',widthFor(v)+'px');
  const dv=parseInt(localStorage.getItem('salesDensity'),10),dval=(dv>=1&&dv<=10)?dv:5;
  dz.value=dval;applyDensity(dval);
  dz.addEventListener('input',()=>{const v=+dz.value;applyDensity(v);localStorage.setItem('salesDensity',v);});
  const pv=localStorage.getItem('salesView'); 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';
  window.__syncSortSel=()=>{if(Array.from(sel.options).some(o=>o.value===sortKey)&&sel.value!==sortKey)sel.value=sortKey;};
})();
// ── panel drag-to-reorder columns (mirrors condos.html — ⠿ handle only starts a move) ──
let pDragKey=null;
$('#fFields').addEventListener('dragstart',e=>{ const row=e.target.closest('.ftog'); if(!row) return;
  if(!e.target.closest('.fdrag')){ e.preventDefault(); return; }
  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; });
// left-rail facets + column toggles
$('#fCity').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.city,c.dataset.ci);c.classList.toggle('active');render();});
$('#fYear').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.year,c.dataset.yr);c.classList.toggle('active');render();});
$('#fBeds').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.beds,c.dataset.bd);c.classList.toggle('active');render();});
// (#fFields change is handled inside column-manager.js now — its onChange calls render)
$('#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(); });
// collapsible rail tabs — all collapsed on load except fieldsSec (matches condos.html)
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'); }); }
// ── charts ──
let cityChart, yearChart;
function drawCharts(d){
  const bc=d.byCity||[];
  if(cityChart) cityChart.destroy();
  cityChart=new Chart($('#cityChart'),{type:'bar',data:{labels:bc.map(c=>c.city),datasets:[{label:'avg $',data:bc.map(c=>c.avg_price),backgroundColor:'#C8A24B'}]},options:{plugins:{legend:{display:false}},scales:{x:{ticks:{color:'#8A93A2',font:{size:9}}},y:{ticks:{color:'#8A93A2'}}}}});
  const by=d.byYear||[];
  if(yearChart) yearChart.destroy();
  yearChart=new Chart($('#yearChart'),{type:'line',data:{labels:by.map(y=>y.yr),datasets:[{label:'sales',data:by.map(y=>y.n),borderColor:'#70AD47',backgroundColor:'rgba(112,173,71,.15)',fill:true,tension:.3}]},options:{plugins:{legend:{display:false}},scales:{x:{ticks:{color:'#8A93A2'}},y:{ticks:{color:'#8A93A2'}}}}});
}
// ── load (preserves the /api/closed-sales fetch) ──
fetch('/api/closed-sales?limit=5000').then(r=>r.json()).then(d=>{
  DATA=d.rows||[];
  const t=d.total||{};
  $('#stat').innerHTML=`<b>${(t.n||0).toLocaleString()}</b> sold condos · avg <b>${money(t.avg_price)}</b> · ${(t.oldest||'').toString().slice(0,10)} → ${(t.newest||'').toString().slice(0,10)} · <span style="color:var(--mut)">${esc(d.source||'')}</span>`;
  if(d.depthNote) $('#depthNote').textContent='ℹ '+d.depthNote;
  autoCols(); if(window.__buildSortSel)window.__buildSortSel(); buildRail(); render(); drawCharts(d);
}).catch(()=>{ $('#count').textContent='failed to load /api/closed-sales'; $('#stat').textContent='sold-comps load error.'; });
</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="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
  <script src="/crcp-theme.js" defer></script><!-- CRCP day/night toggle (auto-injects into header) -->
  <script>
  /* mobile: relocate the filter rail into a ⚙ Filters modal (<820px), back to the sidebar on desktop.
     The rail keeps its own .rsec collapse handler; this only moves the node + toggles the overlay. */
  (function(){
    function boot(){
      var rail=document.querySelector('.rail'), hdr=document.querySelector('header'), btn=document.getElementById('railBtn');
      if(!rail||!hdr||!btn||document.getElementById('railOv')) return;
      var home=document.createComment('sales-rail-home'); rail.parentNode.insertBefore(home,rail);
      var ov=document.createElement('div'); ov.id='railOv'; ov.className='railov';
      var md=document.createElement('div'); md.className='railmd';
      var hd=document.createElement('div'); hd.className='railmd-h'; hd.innerHTML='<span>Filters</span><button type="button" class="railmd-x" aria-label="Close">✕</button>';
      var bd=document.createElement('div'); bd.className='railmd-b';
      md.appendChild(hd); md.appendChild(bd); ov.appendChild(md); document.body.appendChild(ov);
      var open=function(){ov.classList.add('on');}, close=function(){ov.classList.remove('on');};
      btn.addEventListener('click',open);
      hd.querySelector('.railmd-x').addEventListener('click',close);
      ov.addEventListener('click',function(e){ if(e.target===ov) close(); });
      document.addEventListener('keydown',function(e){ if(e.key==='Escape') close(); });
      var mq=window.matchMedia('(max-width: 820px)');
      function place(){ if(mq.matches){ if(rail.parentNode!==bd) bd.appendChild(rail); rail.classList.add('in-modal'); } else { if(home.parentNode&&rail.parentNode!==home.parentNode) home.parentNode.insertBefore(rail,home); rail.classList.remove('in-modal'); close(); } }
      if(mq.addEventListener) mq.addEventListener('change',place); else if(mq.addListener) mq.addListener(place);
      place();
    }
    if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',boot); else boot();
  })();
  </script>
</body>
</html>