← back to Homesonspec
scripts/permit-viewer.mjs
93 lines
// Searchable + mappable building-permit viewer (zero-dependency: Node http + psql).
// Serves a search UI over the building_permits table with a Leaflet map of geocoded
// permits. Local admin tool. Run: node scripts/permit-viewer.mjs (PORT env, default 9977)
import { createServer } from "node:http";
import { execFile } from "node:child_process";
const DBURL = "postgresql://macstudio3@localhost/homesonspec?host=/tmp";
const PORT = Number(process.env.PORT || 9977);
const esc = (s) => String(s ?? "").replace(/'/g, "''").replace(/[;\\]/g, "").slice(0, 80);
function q(sql) {
return new Promise((res) => {
execFile("psql", [DBURL, "-tAc", sql], { maxBuffer: 64 * 1024 * 1024 }, (e, out) => res(e ? "[]" : out.trim() || "[]"));
});
}
const PAGE = `<!doctype html><html><head><meta charset=utf8><title>HomesOnSpec — Building Permits</title>
<meta name=viewport content="width=device-width,initial-scale=1">
<link rel=stylesheet href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<style>
:root{--bg:#0b141a;--pan:#111c24;--ink:#e8f0f0;--mut:#8fb0b0;--acc:#2dd4bf}
*{box-sizing:border-box}body{margin:0;font:14px/1.4 -apple-system,Helvetica,Arial;background:var(--bg);color:var(--ink)}
header{padding:14px 18px;border-bottom:1px solid #1e2f38}h1{margin:0;font-size:18px}.sub{color:var(--mut);font-size:12px;margin-top:2px}
.wrap{display:flex;height:calc(100vh - 58px)}#list{width:46%;overflow:auto;border-right:1px solid #1e2f38}#map{flex:1}
.ctrls{display:flex;gap:8px;padding:10px;position:sticky;top:0;background:var(--bg);border-bottom:1px solid #1e2f38;flex-wrap:wrap}
input,select{background:var(--pan);border:1px solid #274049;color:var(--ink);border-radius:8px;padding:8px 10px;font-size:13px}
input#q{flex:1;min-width:160px}.card{padding:10px 14px;border-bottom:1px solid #16242c;cursor:pointer}.card:hover{background:#0f1c23}
.addr{font-weight:600}.meta{color:var(--mut);font-size:12px;margin-top:2px}.badge{display:inline-block;background:#183a36;color:var(--acc);border-radius:6px;padding:1px 7px;font-size:11px;margin-right:6px}
.count{color:var(--mut);font-size:12px;padding:8px 14px}
</style></head><body>
<header><h1>HomesOnSpec — Building Permits</h1><div class=sub id=stat>loading…</div></header>
<div class=wrap>
<div id=list>
<div class=ctrls>
<input id=q placeholder="search address, contractor, ZIP…">
<select id=type><option value="">All types</option></select>
<select id=minval><option value="0">Any value</option><option value=100000>$100k+</option><option value=500000>$500k+</option><option value=1000000>$1M+</option><option value=5000000>$5M+</option></select>
</div>
<div id=rows></div>
</div>
<div id=map></div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const map=L.map('map').setView([34.02,-118.34],10);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',{maxZoom:19,attribution:'© OSM © CARTO'}).addTo(map);
let layer=L.layerGroup().addTo(map);
async function typeOpts(){const r=await fetch('/api/types').then(r=>r.json());const s=document.getElementById('type');r.forEach(t=>{const o=document.createElement('option');o.value=t;o.textContent=t;s.appendChild(o)})}
async function load(){
const q=document.getElementById('q').value,ty=document.getElementById('type').value,mv=document.getElementById('minval').value;
const d=await fetch('/api/permits?q='+encodeURIComponent(q)+'&type='+encodeURIComponent(ty)+'&minval='+mv).then(r=>r.json());
document.getElementById('stat').textContent=d.total.toLocaleString()+' permits match · '+d.mapped.toLocaleString()+' mapped';
const rows=document.getElementById('rows');rows.innerHTML='';layer.clearLayers();const pts=[];
d.rows.forEach(p=>{
const c=document.createElement('div');c.className='card';
c.innerHTML='<div class=addr>'+(p.address||'—')+', '+(p.city||'')+' '+(p.zip||'')+'</div><div class=meta><span class=badge>'+(p.permit_type||'')+'</span>'+(p.valuation?'$'+Number(p.valuation).toLocaleString():'')+' · '+(p.status||'')+' · '+(p.issued_date||'')+' · '+(p.applicant||'')+'</div>';
if(p.lat){c.onclick=()=>map.setView([p.lat,p.lon],16);pts.push([p.lat,p.lon]);
L.circleMarker([p.lat,p.lon],{radius:4,color:'#fb923c',weight:1,fillOpacity:.7}).bindPopup('<b>'+(p.address||'')+'</b><br>'+(p.permit_type||'')+'<br>'+(p.valuation?'$'+Number(p.valuation).toLocaleString():'')).addTo(layer);}
rows.appendChild(c);
});
if(pts.length)map.fitBounds(pts,{maxZoom:13,padding:[30,30]});
}
document.getElementById('q').addEventListener('input',()=>{clearTimeout(window._t);window._t=setTimeout(load,350)});
document.getElementById('type').onchange=load;document.getElementById('minval').onchange=load;
typeOpts().then(load);
</script></body></html>`;
const server = createServer(async (req, res) => {
const u = new URL(req.url, "http://x");
if (u.pathname === "/") { res.setHeader("content-type", "text/html"); return res.end(PAGE); }
if (u.pathname === "/api/types") {
const j = await q(`select coalesce(json_agg(t),'[]') from (select distinct permit_type from building_permits where permit_type is not null order by 1 limit 60) x(t)`);
res.setHeader("content-type", "application/json"); return res.end(j);
}
if (u.pathname === "/api/permits") {
const term = esc(u.searchParams.get("q") || "");
const type = esc(u.searchParams.get("type") || "");
const minval = Number(u.searchParams.get("minval") || 0) || 0;
const w = ["1=1"];
if (term) w.push(`(address ilike '%${term}%' or applicant ilike '%${term}%' or zip='${term}' or permit_number ilike '%${term}%')`);
if (type) w.push(`permit_type='${type}'`);
if (minval) w.push(`valuation >= ${minval}`);
const where = w.join(" and ");
const total = (await q(`select count(*) from building_permits where ${where}`)).trim() || "0";
const mapped = (await q(`select count(*) from building_permits where ${where} and lat is not null`)).trim() || "0";
const rows = await q(`select coalesce(json_agg(t),'[]') from (select address,city,zip,permit_type,valuation,status,issued_date::text,applicant,lat,lon from building_permits where ${where} order by (lat is not null) desc, valuation desc nulls last limit 400) t`);
res.setHeader("content-type", "application/json");
return res.end(JSON.stringify({ total: Number(total), mapped: Number(mapped), rows: JSON.parse(rows) }));
}
res.statusCode = 404; res.end("not found");
});
server.listen(PORT, () => console.log(`permit-viewer on http://127.0.0.1:${PORT}`));