← back to Commercialrealestate
public/valley-pools.html
353 lines
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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>
<!-- Self-heal: nav-agent grid controls can persist a "removed"/"collapsed" state that hides the whole grid. 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>
<script>/* credential-safe fetch (2026-08-18): resolve EVERY relative fetch against a credential-free base so creds in the page URL (http://user:pass@host/...) don't poison document.baseURI and throw. */(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>
<title>San Fernando Valley · Pool Homes under $1.2M — CRCP</title>
<link rel="stylesheet" href="/vendor/leaflet/leaflet.css">
<style>
:root{--acc-b:#79b8ff;--cols:4;
--t-best:#ff5c8a;--t-value:#4bd268;--t-cut:#e3b341;--t-nego:#79b8ff;--t-mkt:#7d8896;}
*{box-sizing:border-box}
body{margin:0;background:var(--bg,#0d1117);color:var(--ink,#f2f6fb);font-family:-apple-system,BlinkMacSystemFont,Helvetica,Arial,sans-serif;font-size:13px;-webkit-font-smoothing:antialiased}
a{color:var(--blue,#79b8ff);text-decoration:none}
header{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;padding:11px 16px;border-bottom:1px solid var(--line);background:var(--headbg,rgba(13,17,23,.98));position:sticky;top:0;z-index:1200}
header h1{font-size:15px;margin:0;font-weight:700;letter-spacing:.01em}
header .wm{font-weight:800;letter-spacing:.14em;font-size:14px}
header .wm .dot{color:var(--t-value)}
.badge{font-size:11px;color:var(--mut);border:1px solid var(--line);border-radius:20px;padding:2px 9px}
.badge b{color:var(--gold)}
header .nav{margin-left:auto;display:flex;gap:12px}
header .nav a{font-size:13px}
.shell{display:flex;align-items:flex-start}
.rail{width:238px;flex:0 0 238px;border-right:1px solid var(--line);padding:12px;height:calc(100vh - 50px);overflow:auto;position:sticky;top:50px}
.rsec{margin-bottom:15px}
.rsec h4{margin:0 0 7px;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);font-weight:700}
.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;display:inline-flex;align-items:center;gap:5px}
.chip:hover{border-color:var(--blue)}
.chip.active{background:var(--blue);color:var(--onacc);border-color:var(--blue);font-weight:600}
.chip .ct{font-size:10px;opacity:.7}
.chip[data-deal].active{color:var(--onacc)}
.chip.c-best.active{background:var(--t-best);border-color:var(--t-best)}
.chip.c-value.active{background:var(--t-value);border-color:var(--t-value)}
.chip.c-cut.active{background:var(--t-cut);border-color:var(--t-cut)}
.chip.c-nego.active{background:var(--t-nego);border-color:var(--t-nego)}
.rsec input,.rsec select{width:100%;background:var(--card);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 9px;font-size:13px;font-family:inherit}
.rsec .two{display:flex;gap:8px}
.rsec .two input{width:50%}
.legend{display:flex;flex-direction:column;gap:5px;font-size:11px;color:var(--mut)}
.legend .lg{display:flex;align-items:center;gap:6px}
.legend .sw{width:11px;height:11px;border-radius:3px;flex:0 0 11px}
.main{flex:1;min-width:0;padding:12px 16px 60px}
.prov{color:var(--mut);font-size:11px;line-height:1.5;margin-bottom:10px}
.prov b{color:var(--ink)}
.stats{display:flex;gap:18px;flex-wrap:wrap;margin-bottom:10px}
.stat{display:flex;flex-direction:column}.stat b{font-size:19px;line-height:1}.stat span{font-size:11px;color:var(--mut)}
.stat.best b{color:var(--t-best)}.stat.value b{color:var(--t-value)}.stat.cut b{color:var(--t-cut)}
.toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px}
.toolbar .search{flex:1;min-width:170px;background:var(--card);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:13px}
.toolbar select,.toolbar input[type=number]{background:var(--card);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:6px 9px;font-size:13px}
.toolbar label{font-size:11px;color:var(--mut);display:flex;align-items:center;gap:5px}
.count{font-size:12px;color:var(--mut)}
#mapBtn{background:var(--card);color:var(--mut);border:1px solid var(--line);border-radius:8px;padding:6px 11px;font-size:12px;cursor:pointer}
#mapBtn.on{background:var(--t-nego);color:var(--onacc);font-weight:600;border-color:var(--t-nego)}
/* grid cards */
#gridView{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:13px}
.card{background:var(--card);border:1px solid var(--line);border-left:4px solid var(--t-mkt);border-radius:11px;overflow:hidden;display:flex;flex-direction:column;transition:transform .12s,box-shadow .12s}
.card:hover{transform:translateY(-2px)}
.card.t-best{border-left-color:var(--t-best)}.card.t-best:hover{box-shadow:0 0 0 1px var(--t-best)}
.card.t-value{border-left-color:var(--t-value)}.card.t-value:hover{box-shadow:0 0 0 1px var(--t-value)}
.card.t-cut{border-left-color:var(--t-cut)}.card.t-cut:hover{box-shadow:0 0 0 1px var(--t-cut)}
.card.t-nego{border-left-color:var(--t-nego)}
.card .top{padding:11px 12px;display:flex;flex-direction:column;gap:5px;flex:1}
.price{font-size:18px;font-weight:800;display:flex;align-items:baseline;gap:7px}
.wasp{font-size:11px;font-weight:600;color:var(--mut);text-decoration:line-through}
.addr{font-size:13px;font-weight:600;line-height:1.3}
.loc{color:var(--mut);font-size:12px}
.facts{display:flex;gap:9px;flex-wrap:wrap;color:var(--mut);font-size:12px}
.facts b{color:var(--ink)}
.pay{font-size:12px;color:var(--mut)}.pay b{color:var(--blue)}
.badges{display:flex;gap:5px;flex-wrap:wrap}
.pill{font-size:10px;padding:2px 7px;border-radius:16px;font-weight:700;border:1px solid transparent}
.p-best{background:rgba(255,92,138,.16);color:var(--t-best);border-color:rgba(255,92,138,.4)}
.p-value{background:rgba(75,210,104,.16);color:var(--t-value);border-color:rgba(75,210,104,.4)}
.p-cut{background:rgba(227,179,65,.16);color:var(--t-cut);border-color:rgba(227,179,65,.4)}
.p-nego{background:rgba(121,184,255,.14);color:var(--t-nego);border-color:rgba(121,184,255,.35)}
.p-pool{background:rgba(75,210,104,.10);color:var(--t-value);border-color:rgba(75,210,104,.25)}
.p-reg{background:rgba(125,136,150,.14);color:var(--mut);border-color:rgba(125,136,150,.3)}
.card .foot{border-top:1px solid var(--line);padding:8px 12px;display:flex;justify-content:space-between;font-size:12px}
.card .foot .dom{color:var(--mut)}
.notes{border-top:1px solid var(--line);padding:7px 12px}
.note-btn{background:none;border:0;color:var(--mut);font-size:12px;cursor:pointer;padding:0;font-family:inherit}
.note-btn:hover{color:var(--blue)}
.card.has-note .note-btn{color:var(--gold)}
.note-edit{margin-top:6px}
.note-edit textarea{width:100%;min-height:54px;background:var(--bg);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:6px;font:inherit;font-size:12px;resize:vertical}
.note-row{display:flex;align-items:center;gap:8px;margin-top:5px}
.note-save{background:var(--blue);color:var(--onacc);border:0;border-radius:6px;padding:4px 10px;font-size:12px;font-weight:600;cursor:pointer}
.note-meta{font-size:11px;color:var(--mut)}
/* table */
#tableView table{width:100%;border-collapse:collapse}
#tableView th{position:sticky;top:0;background:var(--card);text-align:left;padding:7px 9px;font-size:11px;color:var(--mut);border-bottom:1px solid var(--line);cursor:pointer;white-space:nowrap}
#tableView td{padding:6px 9px;border-bottom:1px solid var(--line);white-space:nowrap}
#tableView td.mono{font-variant-numeric:tabular-nums}
#tableView tr.r-best td:first-child{box-shadow:inset 3px 0 var(--t-best)}
#tableView tr.r-value td:first-child{box-shadow:inset 3px 0 var(--t-value)}
#tableView tr.r-cut td:first-child{box-shadow:inset 3px 0 var(--t-cut)}
#tableView tr.r-nego td:first-child{box-shadow:inset 3px 0 var(--t-nego)}
#map{height:calc(100vh - 170px);min-height:420px;border:1px solid var(--line);border-radius:11px;display:none}
body.mapmode #tableView,body.mapmode #gridView,body.mapmode #listView{display:none!important}
body.mapmode #map{display:block}
.price-lbl{background:rgba(13,17,23,.82);border:1px solid var(--line);border-radius:5px;padding:0 4px;font-size:10.5px;font-weight:800;line-height:1.5;color:var(--ink);box-shadow:none;white-space:nowrap}
.price-lbl::before{display:none}
html[data-theme="light"] .price-lbl{background:rgba(255,255,255,.9)}
.price-lbl.t-best{color:var(--t-best);border-color:var(--t-best)}
.price-lbl.t-value{color:var(--t-value);border-color:var(--t-value)}
.price-lbl.t-cut{color:var(--t-cut);border-color:var(--t-cut)}
.price-lbl.t-nego{color:var(--t-nego)}
@media(max-width:900px){.rail{display:none}}
</style>
<link rel="stylesheet" href="/three-view.css">
<link rel="stylesheet" href="/nav-agent/nav-agent.css">
<link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
</head>
<body>
<header>
<span class="wm">CRCP<span class="dot">.</span></span>
<h1>San Fernando Valley — Pool Homes</h1>
<span class="badge">Single-family · pool · SFV · under <b>$1.2M</b></span>
<div class="nav"><a href="/mls.html">← Listings</a><a href="/">Explorer</a></div>
</header>
<div class="shell">
<aside class="rail">
<div class="rsec"><h4>Max price</h4>
<select id="maxPrice"><option value="1200000">$1,200,000</option><option value="1000000">$1,000,000</option><option value="900000">$900,000</option><option value="800000">$800,000</option><option value="700000">$700,000</option><option value="600000">$600,000</option></select></div>
<div class="rsec"><h4>Beds / baths (min)</h4><div class="two"><input type="number" id="minBeds" placeholder="beds" min="0" step="1"><input type="number" id="minBaths" placeholder="baths" min="0" step="1"></div></div>
<div class="rsec"><h4>Deal tier</h4><div class="chips" id="fDeal">
<span class="chip c-best" data-deal="best">🔥 Best</span>
<span class="chip c-value" data-deal="value">🟢 Value</span>
<span class="chip c-cut" data-deal="cut">🟠 Cut</span>
<span class="chip c-nego" data-deal="nego">🔵 Nego</span></div></div>
<div class="rsec"><h4>Region</h4><div class="chips" id="fRegion"></div></div>
<div class="rsec"><h4>Mortgage estimate</h4><div class="two"><input type="number" id="rate" value="6.75" step="0.125" title="rate %"><input type="number" id="down" value="20" step="5" title="down %"></div>
<div style="font-size:10px;color:var(--mut);margin-top:4px">rate % · down %</div></div>
<div class="rsec"><h4>Deal colors</h4><div class="legend">
<span class="lg"><span class="sw" style="background:var(--t-best)"></span>🔥 Best — below-market & cut</span>
<span class="lg"><span class="sw" style="background:var(--t-value)"></span>🟢 Value — ≥12% under area $/sf</span>
<span class="lg"><span class="sw" style="background:var(--t-cut)"></span>🟠 Price cut</span>
<span class="lg"><span class="sw" style="background:var(--t-nego)"></span>🔵 Negotiable — 30+ days</span>
<span class="lg"><span class="sw" style="background:var(--t-mkt)"></span>⚪ Market-priced</span></div></div>
</aside>
<div class="main">
<div class="stats" id="stats"></div>
<div class="prov" id="prov">Active single-family homes with a pool in the San Fernando Valley under $1,200,000. Feed-first ($0) from Redfin gis-csv (&pool=1 verified has-pool filter); price-history + $/sqft deal-score joined from the CRE sweep table. Payment shown is an estimate only.</div>
<div class="toolbar">
<input class="search" id="q" placeholder="search address, city, zip, region…">
<span class="viewseg tv-seg" id="viewseg"></span>
<button id="mapBtn" title="Map with price tags">◱ Map</button>
<select id="sort"></select>
<label>density <input type="range" id="dens" min="1" max="8" step="1"></label>
<span class="count" id="count"></span>
</div>
<div id="tableView"><table><thead id="head"></thead><tbody id="body"></tbody></table></div>
<div id="gridView"></div>
<div id="listView"></div>
<div id="map"></div>
</div>
</div>
<link rel="stylesheet" href="/crcp-theme.css">
<script src="/vendor/leaflet/leaflet.js"></script>
<script src="/three-view.js"></script>
<script>
const $=s=>document.querySelector(s);
const esc=s=>String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
const fmt=n=>n==null?'—':'$'+Number(n).toLocaleString();
const num=n=>n==null?'—':Number(n).toLocaleString();
let DATA=[],META={},CURRENT=[],TV=null,map=null,layer=null,q='',NOTES={};
const fmtDate=x=>{try{return new Date(x).toLocaleString(undefined,{dateStyle:'medium',timeStyle:'short'})}catch(e){return ''}};
let sortKey='deal',sortDir=1;
let dens=(function(){const v=parseInt(localStorage.getItem('vpDens'),10);return(v>=1&&v<=8)?v:4;})();
const F={maxPrice:1200000,minBeds:null,minBaths:null,region:new Set(),deal:new Set(),rate:6.75,down:20};
// ---- deal tier (priority cascade) ----
function tier(h){const cut=h.price_cut>0,val=h.value_buy,nego=(h.days_on_market!=null&&h.days_on_market>=30);
return (cut&&val)?'best':val?'value':cut?'cut':nego?'nego':'mkt';}
const TIERRANK={best:0,value:1,cut:2,nego:3,mkt:4};
const TIERCOLOR={best:'#ff5c8a',value:'#4bd268',cut:'#e3b341',nego:'#79b8ff',mkt:'#7d8896'};
function pmt(h){const rate=F.rate/100,down=F.down/100,P=h.price*(1-down),r=rate/12,n=360;
const pi=r>0?P*r*Math.pow(1+r,n)/(Math.pow(1+r,n)-1):P/n;
return Math.round(pi+h.price*0.011/12+(h.sqft?h.sqft*0.30:2400)/12);}
// ---- columns (drive table + list + nav-agent field toggles + sort menu) ----
const COLS=[
{k:'deal',l:'Deal',t:'deal',calc:r=>tier(r)},
{k:'price',l:'Price',t:'money'},
{k:'prev_price',l:'Was',t:'money'},
{k:'price_cut',l:'Cut',t:'money'},
{k:'ppsf',l:'$/sf',t:'money'},
{k:'ppsf_vs_region',l:'vs area',t:'pct'},
{k:'pmt',l:'Est /mo',t:'money',calc:r=>pmt(r)},
{k:'beds',l:'Bd',t:'n'},{k:'baths',l:'Ba',t:'n'},{k:'sqft',l:'SqFt',t:'n'},{k:'year_built',l:'Built',t:'y'},
{k:'days_on_market',l:'DOM',t:'n'},
{k:'address',l:'Address',t:'s'},{k:'city',l:'City',t:'s'},{k:'zip',l:'Zip',t:'s'},{k:'region',l:'Region',t:'s'}
];
const cval=(r,c)=>c.calc?c.calc(r):r[c.k];
function cell(r,c){const v=cval(r,c);
if(c.k==='deal'){const t=v,lbl={best:'🔥 Best',value:'🟢 Value',cut:'🟠 Cut',nego:'🔵 Nego',mkt:'⚪ Market'}[t];return `<span class="pill p-${t==='mkt'?'reg':t}">${lbl}</span>`;}
if(c.t==='money')return v==null||v===0&&c.k==='price_cut'?'—':(v==null?'—':'$'+Number(v).toLocaleString());
if(c.t==='pct')return v==null?'—':`<span style="color:${v<0?'var(--t-value)':'var(--mut)'}">${v>0?'+':''}${v}%</span>`;
if(c.t==='n')return v==null?'—':(+v).toLocaleString();
if(c.t==='y')return v==null?'—':v;
return esc(v==null||v===''?'—':v);
}
function pass(r){
if(r.price>F.maxPrice)return false;
if(F.minBeds!=null&&!((r.beds||0)>=F.minBeds))return false;
if(F.minBaths!=null&&!((r.baths||0)>=F.minBaths))return false;
if(F.region.size&&!F.region.has(r.region))return false;
if(F.deal.size&&!F.deal.has(tier(r)))return false;
if(q){const t=q.toLowerCase();return (r.address+' '+r.city+' '+r.zip+' '+r.region).toLowerCase().includes(t);}
return true;
}
function sorted(rows){
if(sortKey==='deal') return rows.slice().sort((a,b)=>TIERRANK[tier(a)]-TIERRANK[tier(b)]||(a.ppsf_vs_region??0)-(b.ppsf_vs_region??0)||a.price-b.price);
const c=COLS.find(x=>x.k===sortKey)||COLS[1];
return rows.slice().sort((a,b)=>{let x=cval(a,c),y=cval(b,c);const xm=(x==null||x===''),ym=(y==null||y==='');
if(xm&&ym)return 0;if(xm)return 1;if(ym)return -1;
if(!isNaN(+x)&&!isNaN(+y)){x=+x;y=+y;}else{x=String(x).toLowerCase();y=String(y).toLowerCase();}
return x<y?-1*sortDir:x>y?sortDir:0;});
}
function cardHTML(h){
const t=tier(h),pp=h.ppsf?('$'+h.ppsf.toLocaleString()+'/sf'):'';
const vs=h.ppsf_vs_region!=null?` <span style="color:${h.ppsf_vs_region<0?'var(--t-value)':'var(--mut)'}">(${h.ppsf_vs_region>0?'+':''}${h.ppsf_vs_region}% vs area)</span>`:'';
const b=[];
if(t==='best')b.push('<span class="pill p-best">🔥 Best deal</span>');
else if(t==='value')b.push('<span class="pill p-value">🟢 Value buy</span>');
else if(t==='cut')b.push('<span class="pill p-cut">🟠 Price cut</span>');
else if(t==='nego')b.push('<span class="pill p-nego">🔵 Negotiable</span>');
if(h.price_cut>0&&t!=='cut'&&t!=='best')b.push('<span class="pill p-cut">↓ '+fmt(h.price_cut)+'</span>');
if(h.has_pool)b.push('<span class="pill p-pool">◔ Pool</span>');
b.push('<span class="pill p-reg">'+esc(h.region)+'</span>');
return `<div class="top">
<div class="price">${fmt(h.price)}${h.price_cut>0?`<span class="wasp">${fmt(h.prev_price)}</span>`:''}</div>
<div class="addr">${esc(h.address)}</div><div class="loc">${esc(h.city)} ${esc(h.zip)}</div>
<div class="facts"><span><b>${num(h.beds)}</b> bd</span><span><b>${num(h.baths)}</b> ba</span><span><b>${num(h.sqft)}</b> sf</span>${h.year_built?`<span>built <b>${h.year_built}</b></span>`:''}${pp?`<span><b>${pp}</b></span>${vs}`:''}</div>
<div class="pay">est <b>$${pmt(h).toLocaleString()}/mo</b> · ${F.down}% down @ ${F.rate}%</div>
<div class="badges">${b.join('')}</div></div>
<div class="foot"><span class="dom">${h.days_on_market!=null?h.days_on_market+(h.days_on_market===1?' day':' days')+' on market':'—'}</span>${h.source?`<a href="${h.source}" target="_blank" rel="noopener noreferrer">Redfin ↗</a>`:''}</div>
<div class="notes" data-noteid="${esc(h.id)}">
<button class="note-btn">📝 ${NOTES[h.id]?'Note':'Add note'}</button>
<div class="note-edit" hidden><textarea placeholder="Private note for this listing…">${esc(NOTES[h.id]?NOTES[h.id].note:'')}</textarea>
<div class="note-row"><button class="note-save">Save</button><span class="note-meta">${NOTES[h.id]?'saved '+fmtDate(NOTES[h.id].updated_at):''}</span></div></div>
</div>`;
}
function renderGrid(rows){const gv=$('#gridView');gv.style.setProperty('--cols',dens);
gv.innerHTML=rows.map(h=>`<div class="card t-${tier(h)}${NOTES[h.id]?' has-note':''}">${cardHTML(h)}</div>`).join('')||'<div style="color:var(--mut);padding:30px">No matches for these filters.</div>';}
// per-card notes (delegated) — save to /api/sfr-notes/:id (per-user via the gate:<user> fallback)
$('#gridView').addEventListener('click',async e=>{
const box=e.target.closest('.notes'); if(!box)return; const id=box.dataset.noteid; const card=box.closest('.card');
if(e.target.closest('.note-btn')){const ed=box.querySelector('.note-edit');ed.hidden=!ed.hidden;if(!ed.hidden)box.querySelector('textarea').focus();return;}
if(e.target.closest('.note-save')){
const note=box.querySelector('textarea').value, meta=box.querySelector('.note-meta'); meta.textContent='saving…';
try{const r=await fetch('/api/sfr-notes/'+encodeURIComponent(id),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({note})});
const j=await r.json();
if(j.ok){ if(j.note)NOTES[id]=j.note; else delete NOTES[id];
meta.textContent=j.note?'saved '+fmtDate(j.note.updated_at):'cleared';
box.querySelector('.note-btn').textContent='📝 '+(NOTES[id]?'Note':'Add note');
card.classList.toggle('has-note',!!NOTES[id]); }
else meta.textContent=j.error||(j.signed_in===false?'sign in to save':'error');
}catch(err){meta.textContent='error saving';}
}
});
function renderTable(rows){
$('#head').innerHTML='<tr>'+COLS.map(c=>`<th data-k="${c.k}">${esc(c.l)}${sortKey===c.k?(sortDir<0?' ↓':' ↑'):''}</th>`).join('')+'</tr>';
const fs=Math.max(10,15-dens);
$('#body').innerHTML=rows.map(h=>`<tr class="r-${tier(h)}" style="font-size:${fs}px">`+COLS.map(c=>`<td class="${['money','n','pct','y'].includes(c.t)?'mono':''}">${cell(h,c)}</td>`).join('')+'</tr>').join('');
$('#head').querySelectorAll('th').forEach(th=>th.addEventListener('click',()=>{const k=th.dataset.k;if(sortKey===k)sortDir*=-1;else{sortKey=k;sortDir=(['address','city','region','zip','deal'].includes(k))?1:-1;}$('#sort').value=k;render();}));
}
function renderMap(rows){
if(!map){map=L.map('map',{scrollWheelZoom:true}).setView([34.19,-118.45],11);
const dark=document.documentElement.getAttribute('data-theme')!=='light';
L.tileLayer(dark?'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png':'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',{attribution:'© OpenStreetMap © CARTO',maxZoom:19}).addTo(map);}
if(layer)layer.remove();layer=L.layerGroup().addTo(map);
const pts=[],shortP=n=>n>=1e6?('$'+(n/1e6).toFixed(n%1e6?2:1).replace(/\.0$/,'')+'M'):('$'+Math.round(n/1000)+'k');
for(const h of rows){if(h.lat==null||h.lng==null)continue;const t=tier(h),c=TIERCOLOR[t];
const m=L.circleMarker([h.lat,h.lng],{radius:t==='mkt'?5:7,color:c,weight:2,fillColor:c,fillOpacity:t==='mkt'?.35:.75});
m.bindTooltip(shortP(h.price),{permanent:true,direction:'right',offset:[7,0],className:'price-lbl t-'+t});
m.bindPopup(`<b>${fmt(h.price)}</b>${h.price_cut>0?` <s style="color:#888">${fmt(h.prev_price)}</s>`:''}<br>${esc(h.address)}, ${esc(h.city)}<br>${num(h.beds)}bd/${num(h.baths)}ba · ${num(h.sqft)}sf${h.ppsf?' · $'+h.ppsf+'/sf':''}<br>est $${pmt(h).toLocaleString()}/mo${h.source?`<br><a href="${h.source}" target="_blank" rel="noopener">View on Redfin ↗</a>`:''}`);
m.addTo(layer);pts.push([h.lat,h.lng]);}
if(pts.length){try{map.fitBounds(pts,{padding:[30,30],maxZoom:13})}catch(e){}}
setTimeout(()=>map.invalidateSize(),60);
}
function render(){
CURRENT=sorted(DATA.filter(pass));
const rows=CURRENT;
const nb=rows.filter(r=>tier(r)==='best').length,nv=rows.filter(r=>r.value_buy).length,nc=rows.filter(r=>r.price_cut>0).length;
const prices=rows.map(r=>r.price).sort((a,b)=>a-b),med=prices.length?prices[Math.floor(prices.length/2)]:0;
$('#stats').innerHTML=`<div class="stat"><b>${rows.length}</b><span>homes shown</span></div>`+
`<div class="stat"><b>${prices.length?fmt(prices[0]).replace('$','$'):'—'}</b><span>lowest</span></div>`+
`<div class="stat"><b>${fmt(med)}</b><span>median</span></div>`+
`<div class="stat best"><b>${nb}</b><span>🔥 best</span></div>`+
`<div class="stat value"><b>${nv}</b><span>🟢 value</span></div>`+
`<div class="stat cut"><b>${nc}</b><span>🟠 cut</span></div>`;
$('#count').textContent=rows.length+' of '+DATA.length;
if(TV)TV.render();else{renderGrid(rows);renderTable(rows);}
if(document.body.classList.contains('mapmode'))renderMap(rows);
}
// ---- rail: region chips ----
function buildRail(){
const rc={};DATA.forEach(r=>{if(r.region)rc[r.region]=(rc[r.region]||0)+1;});
$('#fRegion').innerHTML=Object.entries(rc).sort((a,b)=>b[1]-a[1]).map(([k,c])=>`<span class="chip" data-reg="${esc(k)}">${esc(k)}<span class="ct">${c}</span></span>`).join('');
}
function tog(set,v){set.has(v)?set.delete(v):set.add(v);}
$('#fRegion').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.region,c.dataset.reg);c.classList.toggle('active');render();});
$('#fDeal').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.deal,c.dataset.deal);c.classList.toggle('active');render();});
$('#maxPrice').addEventListener('change',e=>{F.maxPrice=+e.target.value;render();});
$('#minBeds').addEventListener('input',e=>{F.minBeds=e.target.value?+e.target.value:null;render();});
$('#minBaths').addEventListener('input',e=>{F.minBaths=e.target.value?+e.target.value:null;render();});
$('#rate').addEventListener('input',e=>{F.rate=+e.target.value||6.75;render();});
$('#down').addEventListener('input',e=>{F.down=+e.target.value||0;render();});
$('#q').addEventListener('input',e=>{q=e.target.value.trim();render();});
$('#sort').addEventListener('change',e=>{sortKey=e.target.value;sortDir=(['address','city','region','zip','deal'].includes(sortKey))?1:-1;render();});
$('#dens').addEventListener('input',e=>{dens=+e.target.value;localStorage.setItem('vpDens',dens);render();});
$('#mapBtn').addEventListener('click',()=>{const on=document.body.classList.toggle('mapmode');$('#mapBtn').classList.toggle('on',on);if(on){renderMap(CURRENT);setTimeout(()=>map&&map.invalidateSize(),80);}});
// sort menu
$('#sort').innerHTML=`<option value="deal">Sort: Best deals</option>`+COLS.filter(c=>c.k!=='deal').map(c=>`<option value="${c.k}">Sort: ${esc(c.l)}</option>`).join('');
$('#dens').value=dens;
Promise.all([
fetch('/data/valley-pools.json').then(r=>r.json()),
fetch('/api/sfr-notes').then(r=>r.json()).catch(()=>({notes:{}}))
]).then(([j,n])=>{
DATA=j.homes||[];META=j;NOTES=(n&&n.notes)||{};buildRail();
// three-view (grid/list/table) — sort owned by the page (#sort + header clicks); TV adds view modes + compact list + windowing
TV=ThreeView.mount({seg:'#viewseg',gridMount:'#gridView',listMount:'#listView',tableMount:'#tableView',
getRows:()=>CURRENT,
fields:COLS.map(c=>({k:c.k,l:c.l,html:true,calc:r=>cell(r,c)})),
title:r=>({name:(r.address||'—'),sub:(r.city||'')+' '+(r.zip||'')}),
renderGrid:renderGrid,renderTable:renderTable,
storageKey:'vpools',defaultView:'grid',views:['grid','list','table']});
render();
$('#prov').innerHTML+=`<br>Generated ${new Date(META.generated).toLocaleString(undefined,{dateStyle:'medium',timeStyle:'short'})} · ${DATA.length} homes · ${META.price_cuts||0} price cuts · ${META.value_buys||0} value buys · ${META.regions_ok||0} Valley regions · $0 feed-first.`;
}).catch(e=>{$('#count').textContent='failed to load /data/valley-pools.json — '+e.message;});
</script>
<script src="/corner-nav.js" defer></script>
<script src="/nav-agent/nav-agent.js" defer></script>
<script src="/filter-modal.js" defer></script>
<script src="/crcp-theme.js" defer></script>
<script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
</body>
</html>