← 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 & 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,'&').replace(/</g,'<').replace(/"/g,'"');
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>