[object Object]

← back to La Socrata Ingester

LA Building Permits viewer frontend: faceted rail, search/sort/density, detail modal

bbdafa5c3b7d4a43bf0d95e9bed808716bcb9518 · 2026-08-11 08:30:56 -0700 · Steve Abrams

- public/index.html: professional teal/neutral data-tool UI over /api/permits + /api/facets
- Left collapsible facet rail (Era/Work Type/Building Type/Status/CD/ZIP) with live counts, click-to-filter, active teal badge; active-facet options stay switchable (refetch minus own filter)
- Top bar: debounced address search, live count, sort (newest/value/oldest/address), density slider -> --cols; sort+density persist in localStorage
- Cards: address, work/building badges, formatted value, issue date + status pill, CD/era, assessed value/year built/sqft when present
- Detail modal (draggable+resizable, persisted): full permit + parcel enrichment, Google Maps link, clickable filter values, raw-JSON toggle
- Load-more pagination (no infinite scroll); stale-response seq guards on facets+permits
- server.js: existing viewer server (was untracked)

Files touched

Diff

commit bbdafa5c3b7d4a43bf0d95e9bed808716bcb9518
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Aug 11 08:30:56 2026 -0700

    LA Building Permits viewer frontend: faceted rail, search/sort/density, detail modal
    
    - public/index.html: professional teal/neutral data-tool UI over /api/permits + /api/facets
    - Left collapsible facet rail (Era/Work Type/Building Type/Status/CD/ZIP) with live counts, click-to-filter, active teal badge; active-facet options stay switchable (refetch minus own filter)
    - Top bar: debounced address search, live count, sort (newest/value/oldest/address), density slider -> --cols; sort+density persist in localStorage
    - Cards: address, work/building badges, formatted value, issue date + status pill, CD/era, assessed value/year built/sqft when present
    - Detail modal (draggable+resizable, persisted): full permit + parcel enrichment, Google Maps link, clickable filter values, raw-JSON toggle
    - Load-more pagination (no infinite scroll); stale-response seq guards on facets+permits
    - server.js: existing viewer server (was untracked)
---
 viewer/.gitignore        |   5 +
 viewer/public/index.html | 481 +++++++++++++++++++++++++++++++++++++++++++++++
 viewer/server.js         | 140 ++++++++++++++
 3 files changed, 626 insertions(+)

diff --git a/viewer/.gitignore b/viewer/.gitignore
new file mode 100644
index 0000000..400dbda
--- /dev/null
+++ b/viewer/.gitignore
@@ -0,0 +1,5 @@
+.playwright-mcp/
+*.png
+node_modules/
+*.log
+.DS_Store
diff --git a/viewer/public/index.html b/viewer/public/index.html
new file mode 100644
index 0000000..d242a23
--- /dev/null
+++ b/viewer/public/index.html
@@ -0,0 +1,481 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>LA Building Permits</title>
+<style>
+  :root{
+    --bg:#f4f5f7; --panel:#ffffff; --ink:#1b1f24; --ink-soft:#5b636d; --ink-faint:#8a919b;
+    --line:#e2e5ea; --line-strong:#d3d8df; --teal:#0d9488; --teal-ink:#0b7d72; --teal-bg:#e6f6f4;
+    --amber:#b45309; --shadow:0 1px 2px rgba(16,24,40,.06),0 1px 3px rgba(16,24,40,.04);
+    --shadow-lg:0 12px 40px rgba(16,24,40,.22);
+    --r:8px; --font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
+    --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
+  }
+  *{box-sizing:border-box}
+  html,body{margin:0;height:100%}
+  body{background:var(--bg);color:var(--ink);font:14px/1.45 var(--font);-webkit-font-smoothing:antialiased}
+  button{font:inherit;cursor:pointer;color:inherit}
+  input,select{font:inherit}
+
+  /* ---------- layout ---------- */
+  .app{display:grid;grid-template-columns:280px 1fr;grid-template-rows:auto 1fr;height:100vh}
+  header.top{grid-column:1/3;background:var(--panel);border-bottom:1px solid var(--line);padding:10px 16px;display:flex;align-items:center;gap:14px;flex-wrap:wrap;z-index:5}
+  aside.rail{border-right:1px solid var(--line);background:var(--panel);overflow-y:auto;padding:6px 0 40px}
+  main.results{overflow-y:auto;padding:16px}
+
+  /* ---------- top bar ---------- */
+  .brand{display:flex;align-items:baseline;gap:8px;min-width:210px}
+  .brand b{font-size:15px;letter-spacing:-.2px}
+  .brand .tag{font-size:11px;color:var(--ink-faint);text-transform:uppercase;letter-spacing:.6px}
+  .search{position:relative;flex:1;min-width:220px;max-width:520px}
+  .search input{width:100%;padding:9px 12px 9px 34px;border:1px solid var(--line-strong);border-radius:var(--r);background:#fff;outline:none}
+  .search input:focus{border-color:var(--teal);box-shadow:0 0 0 3px var(--teal-bg)}
+  .search svg{position:absolute;left:10px;top:50%;transform:translateY(-50%);width:16px;height:16px;color:var(--ink-faint)}
+  .count{font-size:13px;color:var(--ink-soft);white-space:nowrap}
+  .count b{color:var(--ink);font-variant-numeric:tabular-nums}
+  .ctrl{display:flex;align-items:center;gap:6px;color:var(--ink-soft);font-size:12px}
+  .ctrl select{padding:7px 8px;border:1px solid var(--line-strong);border-radius:6px;background:#fff;outline:none}
+  .ctrl select:focus{border-color:var(--teal)}
+  .dens{display:flex;align-items:center;gap:8px}
+  .dens input[type=range]{width:104px;accent-color:var(--teal)}
+
+  /* ---------- active filter chips ---------- */
+  .chips{grid-column:1/3;display:flex;gap:8px;flex-wrap:wrap;padding:8px 16px 0;background:var(--bg)}
+  .chips:empty{display:none}
+  .chip{display:inline-flex;align-items:center;gap:6px;background:var(--teal-bg);color:var(--teal-ink);border:1px solid #bfe8e2;border-radius:999px;padding:3px 6px 3px 10px;font-size:12px;font-weight:600}
+  .chip button{background:none;border:none;color:var(--teal-ink);line-height:1;font-size:14px;padding:0 2px;opacity:.7}
+  .chip button:hover{opacity:1}
+  .chip .clr-all{color:var(--ink-soft);font-weight:600;text-decoration:underline;background:none;border:none;font-size:12px}
+
+  /* ---------- rail facets ---------- */
+  .rail-hd{padding:12px 16px 6px;font-size:11px;font-weight:700;letter-spacing:.7px;text-transform:uppercase;color:var(--ink-faint)}
+  .minirow{padding:0 12px 6px;display:flex;gap:8px}
+  .minirow label{flex:1;display:flex;flex-direction:column;gap:3px;font-size:11px;color:var(--ink-faint);text-transform:uppercase;letter-spacing:.4px}
+  .minirow input{padding:6px 8px;border:1px solid var(--line-strong);border-radius:6px;outline:none;width:100%}
+  .minirow input:focus{border-color:var(--teal)}
+  .facet{border-top:1px solid var(--line)}
+  .facet:first-of-type{border-top:none}
+  .facet>.fhd{width:100%;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 14px;background:none;border:none;text-align:left}
+  .facet>.fhd:hover{background:#fafbfc}
+  .fhd .ttl{font-weight:600;font-size:13px;display:flex;align-items:center;gap:7px}
+  .fhd .caret{color:var(--ink-faint);transition:transform .15s;font-size:10px}
+  .facet.collapsed .caret{transform:rotate(-90deg)}
+  .facet.collapsed .fbody{display:none}
+  .fhd .seldot{width:6px;height:6px;border-radius:50%;background:var(--teal);display:none}
+  .facet.has-sel .seldot{display:inline-block}
+  .fbody{max-height:260px;overflow-y:auto;padding:2px 8px 8px}
+  .fv{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;padding:5px 8px;border:none;background:none;border-radius:6px;text-align:left;color:var(--ink-soft)}
+  .fv:hover{background:var(--teal-bg);color:var(--teal-ink)}
+  .fv.active{background:var(--teal);color:#fff}
+  .fv.active .fc{color:#fff;background:rgba(255,255,255,.22)}
+  .fv .fname{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:12.5px}
+  .fv .fc{font-size:11px;color:var(--ink-faint);background:#eef0f3;border-radius:999px;padding:1px 7px;font-variant-numeric:tabular-nums;flex:none}
+
+  /* ---------- cards ---------- */
+  .grid{display:grid;grid-template-columns:repeat(var(--cols,3),minmax(0,1fr));gap:12px}
+  .card{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:13px 14px;display:flex;flex-direction:column;gap:9px;box-shadow:var(--shadow);cursor:pointer;transition:border-color .12s,box-shadow .12s;text-align:left;width:100%}
+  .card:hover{border-color:var(--teal);box-shadow:0 2px 8px rgba(13,148,136,.14)}
+  .card .addr{font-weight:700;font-size:14.5px;letter-spacing:-.2px;line-height:1.25}
+  .card .badges{display:flex;flex-wrap:wrap;gap:5px}
+  .badge{font-size:11px;font-weight:600;padding:2px 8px;border-radius:5px;background:#eef1f4;color:#485058;white-space:nowrap}
+  .badge.wt{background:#eaf1fb;color:#2b5aa0}
+  .badge.bt{background:#f1eefc;color:#5b45a6}
+  .val{font-size:19px;font-weight:700;letter-spacing:-.4px;font-variant-numeric:tabular-nums}
+  .val.zero{color:var(--ink-faint);font-weight:600;font-size:14px}
+  .metarow{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:12px;color:var(--ink-soft)}
+  .pill{font-size:11px;font-weight:600;padding:2px 9px;border-radius:999px;border:1px solid var(--line-strong);background:#fff;white-space:nowrap}
+  .pill.ok{background:#eafaf1;color:#1a7f47;border-color:#bce9cf}
+  .pill.warn{background:#fef6e7;color:#a16207;border-color:#f5e0a8}
+  .pill.mut{background:#f3f4f6;color:#6b7280}
+  .subrow{display:flex;flex-wrap:wrap;gap:5px 14px;font-size:12px;color:var(--ink-soft);border-top:1px dashed var(--line);padding-top:8px}
+  .subrow span b{color:var(--ink);font-weight:600}
+  .subrow .parcel{color:var(--teal-ink)}
+  .era-tag{font-size:10.5px;color:var(--ink-faint);text-transform:uppercase;letter-spacing:.5px}
+
+  /* ---------- load more / states ---------- */
+  .foot{display:flex;justify-content:center;padding:22px 0 8px}
+  .loadmore{padding:10px 26px;border:1px solid var(--line-strong);border-radius:var(--r);background:#fff;font-weight:600;color:var(--ink)}
+  .loadmore:hover:not(:disabled){border-color:var(--teal);color:var(--teal-ink)}
+  .loadmore:disabled{opacity:.5;cursor:default}
+  .empty,.loading{text-align:center;color:var(--ink-faint);padding:60px 20px;font-size:14px}
+  .skel{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);height:150px;box-shadow:var(--shadow);position:relative;overflow:hidden}
+  .skel::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(0,0,0,.035),transparent);animation:sh 1.1s infinite}
+  @keyframes sh{100%{transform:translateX(100%)}}
+
+  /* ---------- modal ---------- */
+  .scrim{position:fixed;inset:0;background:rgba(16,24,40,.34);display:none;z-index:40}
+  .scrim.open{display:block}
+  .modal{position:fixed;top:80px;left:50%;transform:translateX(-50%);width:min(680px,94vw);max-height:82vh;background:var(--panel);border:1px solid var(--line-strong);border-radius:12px;box-shadow:var(--shadow-lg);display:none;flex-direction:column;z-index:41;overflow:hidden}
+  .modal.open{display:flex}
+  .m-hd{display:flex;align-items:flex-start;gap:12px;padding:14px 16px;border-bottom:1px solid var(--line);cursor:move;user-select:none;background:linear-gradient(#fff,#fcfdfe)}
+  .m-hd .h-txt{flex:1;min-width:0}
+  .m-hd .h-addr{font-weight:700;font-size:16px;letter-spacing:-.2px}
+  .m-hd .h-sub{font-size:12px;color:var(--ink-soft);margin-top:2px}
+  .m-hd .x{background:none;border:none;font-size:22px;line-height:1;color:var(--ink-faint);padding:0 4px}
+  .m-hd .x:hover{color:var(--ink)}
+  .m-bd{padding:14px 16px;overflow-y:auto}
+  .m-val{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;margin-bottom:14px}
+  .m-val .big{font-size:26px;font-weight:800;letter-spacing:-.6px;font-variant-numeric:tabular-nums}
+  .kv{display:grid;grid-template-columns:150px 1fr;gap:2px 14px;font-size:13px}
+  .kv dt{color:var(--ink-faint);padding:6px 0;border-top:1px solid var(--line)}
+  .kv dd{margin:0;padding:6px 0;border-top:1px solid var(--line);color:var(--ink);word-break:break-word}
+  .kv dd a{color:var(--teal-ink);text-decoration:none;font-weight:600}
+  .kv dd a:hover{text-decoration:underline}
+  .kv .flt{cursor:pointer;color:var(--teal-ink);text-decoration:underline dotted;font-weight:600}
+  .sect{margin-top:16px;font-size:11px;font-weight:700;letter-spacing:.6px;text-transform:uppercase;color:var(--ink-faint)}
+  .desc{margin-top:8px;font-size:13px;color:var(--ink-soft);background:#f8f9fb;border:1px solid var(--line);border-radius:8px;padding:10px 12px}
+  .m-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:16px}
+  .m-btn{padding:8px 14px;border:1px solid var(--line-strong);border-radius:8px;background:#fff;font-weight:600;font-size:13px;color:var(--ink);text-decoration:none;display:inline-flex;align-items:center;gap:6px}
+  .m-btn:hover{border-color:var(--teal);color:var(--teal-ink)}
+  .m-btn.primary{background:var(--teal);border-color:var(--teal);color:#fff}
+  .m-btn.primary:hover{background:var(--teal-ink);color:#fff}
+  pre.raw{display:none;margin-top:10px;background:#0f172a;color:#cbd5e1;border-radius:8px;padding:12px;font:12px/1.5 var(--mono);overflow:auto;max-height:320px}
+  pre.raw.open{display:block}
+  .rz{position:absolute;right:2px;bottom:2px;width:16px;height:16px;cursor:nwse-resize;color:var(--ink-faint)}
+
+  @media(max-width:820px){.app{grid-template-columns:1fr;grid-template-rows:auto auto 1fr}
+    aside.rail{max-height:230px;border-right:none;border-bottom:1px solid var(--line)}}
+</style>
+</head>
+<body>
+<div class="app">
+  <header class="top">
+    <div class="brand"><b>LA Building Permits</b><span class="tag">City of Los Angeles</span></div>
+    <div class="search">
+      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
+      <input id="q" type="search" placeholder="Search by address…" autocomplete="off" spellcheck="false">
+    </div>
+    <div class="count" id="count">Loading…</div>
+    <div class="ctrl">Sort
+      <select id="sort">
+        <option value="newest">Newest</option>
+        <option value="value_desc">Value high → low</option>
+        <option value="value_asc">Value low → high</option>
+        <option value="oldest">Oldest</option>
+        <option value="address">Address A→Z</option>
+      </select>
+    </div>
+    <div class="ctrl dens">Density
+      <input id="dens" type="range" min="1" max="6" step="1" value="3">
+    </div>
+  </header>
+  <div class="chips" id="chips"></div>
+
+  <aside class="rail" id="rail">
+    <div class="rail-hd">Filters</div>
+    <div class="minirow">
+      <label>Min value $<input id="minval" type="number" min="0" step="1000" placeholder="any" inputmode="numeric"></label>
+      <label>Issued since<input id="since" type="date"></label>
+    </div>
+    <div id="facets"></div>
+  </aside>
+
+  <main class="results">
+    <div id="grid" class="grid"></div>
+    <div class="foot"><button id="loadmore" class="loadmore" style="display:none">Load more</button></div>
+  </main>
+</div>
+
+<!-- detail modal -->
+<div class="scrim" id="scrim"></div>
+<div class="modal" id="modal" role="dialog" aria-modal="true">
+  <div class="m-hd" id="mHd">
+    <div class="h-txt"><div class="h-addr" id="mAddr">—</div><div class="h-sub" id="mSub"></div></div>
+    <button class="x" id="mClose" aria-label="Close">&times;</button>
+  </div>
+  <div class="m-bd" id="mBody"></div>
+  <svg class="rz" id="mRz" viewBox="0 0 16 16" fill="currentColor"><path d="M16 16H12L16 12V16ZM16 9H9L16 2V9ZM16 5H13L16 2V5Z" opacity=".6"/><circle cx="14" cy="14" r="1.3"/><circle cx="14" cy="10" r="1.3"/><circle cx="10" cy="14" r="1.3"/></svg>
+</div>
+
+<script>
+const API = location.origin;
+const AUTH = {credentials:'include'};
+const $ = s=>document.querySelector(s);
+const el=(t,c,h)=>{const e=document.createElement(t);if(c)e.className=c;if(h!=null)e.innerHTML=h;return e;};
+
+// ---- filter definitions ----
+const FACETS = [
+  {key:'era',           label:'Era'},
+  {key:'work_type',     label:'Work Type'},
+  {key:'building_type', label:'Building Type'},
+  {key:'status',        label:'Status'},
+  {key:'cd',            label:'Council District'},
+  {key:'zip',           label:'ZIP'},
+];
+const ERA_ORDER = {'2020-present':0,'2010-2019':1,'pre-2010':2};
+
+// ---- persisted UI prefs ----
+const LS = {
+  get sort(){return localStorage.getItem('lap.sort')||'newest';},
+  set sort(v){localStorage.setItem('lap.sort',v);},
+  get dens(){return localStorage.getItem('lap.dens')||'3';},
+  set dens(v){localStorage.setItem('lap.dens',v);},
+  get collapsed(){try{return JSON.parse(localStorage.getItem('lap.collapsed'))||{};}catch(e){return{};}},
+  set collapsed(o){localStorage.setItem('lap.collapsed',JSON.stringify(o));},
+};
+
+// ---- state ----
+const state = {q:'',era:'',work_type:'',building_type:'',status:'',cd:'',zip:'',min_value:'',since:'',
+  sort:LS.sort, page:1, limit:60, total:0, loaded:0, busy:false};
+const FILTER_KEYS = ['q','era','work_type','building_type','status','cd','zip','min_value','since'];
+
+// ---- formatting ----
+const nfmt = new Intl.NumberFormat('en-US');
+const money = v=>{const n=Number(v); if(!n||n<=0) return null; return '$'+nfmt.format(Math.round(n));};
+const fmtDate = v=>{ if(!v) return '—'; const d=new Date(v); if(isNaN(d)) return '—';
+  return d.toLocaleDateString('en-US',{year:'numeric',month:'short',day:'numeric'}); };
+const statusClass = s=>{ const t=(s||'').toLowerCase();
+  if(/final|issued|cofo|complete|approved/.test(t)) return 'ok';
+  if(/expired|revoked|denied|withdrawn|cancel|void|abandon/.test(t)) return 'warn';
+  return 'mut'; };
+const esc = s=> String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
+
+function qs(extra, omit){
+  const p=new URLSearchParams();
+  FILTER_KEYS.forEach(k=>{ if(k!==omit && state[k]!=='' && state[k]!=null) p.set(k,state[k]); });
+  if(extra) Object.entries(extra).forEach(([k,v])=>p.set(k,v));
+  return p.toString();
+}
+
+// ---- data loads ----
+// Sequence guards: a slower earlier request must never overwrite a newer render.
+let facetSeq=0, permitSeq=0;
+async function loadFacets(){
+  const my=++facetSeq;
+  try{
+    const r = await fetch(`${API}/api/facets?${qs()}`,AUTH);
+    const data = await r.json();
+    // A facet with an active selection would otherwise collapse to just that value
+    // (server applies every filter to every facet). Refetch each active dimension
+    // WITHOUT its own filter so its alternative values stay switchable.
+    const activeFacetKeys = FACETS.map(f=>f.key).filter(k=>state[k]!=='' && state[k]!=null);
+    await Promise.all(activeFacetKeys.map(async k=>{
+      try{ const alt = await (await fetch(`${API}/api/facets?${qs(null,k)}`,AUTH)).json(); data[k]=alt[k]; }catch(e){}
+    }));
+    if(my!==facetSeq) return;        // superseded by a newer filter change
+    renderFacets(data);
+  }catch(e){ /* keep prior facets on error */ }
+}
+async function loadPermits(reset){
+  if(state.busy && !reset) return;
+  const my = reset ? ++permitSeq : permitSeq;
+  state.busy=true;
+  if(reset){ state.page=1; state.loaded=0; renderSkeleton(); }
+  else { $('#loadmore').disabled=true; $('#loadmore').textContent='Loading…'; }
+  try{
+    const r = await fetch(`${API}/api/permits?${qs({sort:state.sort,page:state.page,limit:state.limit})}`,AUTH);
+    const data = await r.json();
+    if(reset && my!==permitSeq) return;   // a newer reset superseded this one
+    state.total = data.total; state.loaded += data.rows.length;
+    renderCards(data.rows, reset);
+    updateCount();
+    const more = state.loaded < state.total;
+    const lm=$('#loadmore');
+    lm.style.display = more ? '' : 'none';
+    lm.disabled=false; lm.textContent = `Load more (${nfmt.format(state.total-state.loaded)} more)`;
+  }catch(e){
+    if(reset) $('#grid').innerHTML = `<div class="empty">Failed to load permits — ${esc(e.message)}</div>`;
+  }finally{ state.busy=false; }
+}
+function reloadAll(){ loadPermits(true); loadFacets(); syncChips(); }
+
+// ---- render: facets ----
+function renderFacets(data){
+  const host=$('#facets'); const collapsed=LS.collapsed;
+  host.innerHTML='';
+  FACETS.forEach(({key,label})=>{
+    let vals=(data[key]||[]).slice();
+    if(key==='era') vals.sort((a,b)=>(ERA_ORDER[a.v]??9)-(ERA_ORDER[b.v]??9));
+    if(key==='cd')  vals.sort((a,b)=>(+a.v||99)-(+b.v||99));
+    const sel = state[key];
+    const sec = el('div','facet'+(collapsed[key]?' collapsed':'')+(sel?' has-sel':''));
+    const hd = el('button','fhd',
+      `<span class="ttl"><span class="seldot"></span>${label}</span><span class="caret">▼</span>`);
+    hd.onclick=()=>{ const c=LS.collapsed; c[key]=!sec.classList.contains('collapsed'); LS.collapsed=c; sec.classList.toggle('collapsed'); };
+    const body=el('div','fbody');
+    vals.forEach(({v,c})=>{
+      const active = String(sel)===String(v);
+      const b=el('button','fv'+(active?' active':''),
+        `<span class="fname">${esc(v)}</span><span class="fc">${nfmt.format(c)}</span>`);
+      b.title=`${v} · ${nfmt.format(c)}`;
+      b.onclick=()=>{ setFilter(key, active?'':v); };
+      body.appendChild(b);
+    });
+    sec.appendChild(hd); sec.appendChild(body); host.appendChild(sec);
+  });
+}
+
+function setFilter(key,val){
+  state[key]=val; state.page=1;
+  reloadAll();
+}
+
+// ---- render: cards ----
+function renderSkeleton(){ const g=$('#grid'); g.innerHTML=''; for(let i=0;i<9;i++) g.appendChild(el('div','skel')); }
+function renderCards(rows, reset){
+  const g=$('#grid');
+  if(reset){ g.innerHTML=''; if(!rows.length){ g.innerHTML='<div class="empty">No permits match these filters.</div>'; return; } }
+  rows.forEach(r=>g.appendChild(cardEl(r)));
+}
+function cardEl(r){
+  const c=el('button','card'); c.type='button';
+  const m=money(r.project_value);
+  const enrich=[];
+  if(r.assessed_value) enrich.push(`<span>Assessed <b>${money(r.assessed_value)||'—'}</b></span>`);
+  if(r.year_built && Number(r.year_built)>0) enrich.push(`<span>Built <b>${esc(r.year_built)}</b></span>`);
+  if(r.sqft && Number(r.sqft)>0) enrich.push(`<span><b>${nfmt.format(Number(r.sqft))}</b> sqft</span>`);
+  c.innerHTML=`
+    <div class="addr">${esc(r.address||'—')}</div>
+    <div class="badges">
+      ${r.work_type?`<span class="badge wt">${esc(r.work_type)}</span>`:''}
+      ${r.building_type?`<span class="badge bt">${esc(r.building_type)}</span>`:''}
+    </div>
+    ${m?`<div class="val">${m}</div>`:`<div class="val zero">No stated value</div>`}
+    <div class="metarow">
+      <span>${fmtDate(r.issue_date)}</span>
+      <span class="pill ${statusClass(r.status)}">${esc(r.status||'—')}</span>
+    </div>
+    <div class="metarow">
+      <span class="era-tag">${esc(r.era||'')}</span>
+      <span>${r.cd?('CD '+esc(r.cd)):''}${r.zip?(' · '+esc(r.zip)):''}</span>
+    </div>
+    ${enrich.length?`<div class="subrow parcel">${enrich.join('')}</div>`:''}`;
+  c.onclick=()=>openDetail(r.permit_nbr, r);
+  return c;
+}
+function updateCount(){
+  $('#count').innerHTML = `<b>${nfmt.format(state.total)}</b> permit${state.total===1?'':'s'} · showing <b>${nfmt.format(state.loaded)}</b>`;
+}
+
+// ---- active chips ----
+function labelFor(k){return ({q:'Search',era:'Era',work_type:'Work',building_type:'Building',status:'Status',cd:'CD',zip:'ZIP',min_value:'Min $',since:'Since'})[k]||k;}
+function syncChips(){
+  const host=$('#chips'); host.innerHTML='';
+  const active = FILTER_KEYS.filter(k=>state[k]!=='' && state[k]!=null);
+  if(!active.length) return;
+  active.forEach(k=>{
+    let disp=state[k];
+    if(k==='min_value') disp='≥ $'+nfmt.format(state[k]);
+    if(k==='since') disp='since '+fmtDate(state[k]);
+    const chip=el('span','chip',`${labelFor(k)}: ${esc(disp)}<button title="Remove">&times;</button>`);
+    chip.querySelector('button').onclick=()=>clearFilter(k);
+    host.appendChild(chip);
+  });
+  const all=el('button','clr-all',null,'Clear all'); all.textContent='Clear all';
+  all.onclick=clearAll; host.appendChild(all);
+}
+function clearFilter(k){
+  state[k]='';
+  if(k==='q') $('#q').value='';
+  if(k==='min_value') $('#minval').value='';
+  if(k==='since') $('#since').value='';
+  reloadAll();
+}
+function clearAll(){
+  FILTER_KEYS.forEach(k=>state[k]='');
+  $('#q').value=''; $('#minval').value=''; $('#since').value='';
+  reloadAll();
+}
+
+// ---- detail modal ----
+const modal=$('#modal'), scrim=$('#scrim');
+async function openDetail(nbr, fallback){
+  modal.classList.add('open'); scrim.classList.add('open');
+  $('#mAddr').textContent = fallback?.address || nbr;
+  $('#mSub').textContent = 'Loading permit '+nbr+'…';
+  $('#mBody').innerHTML = '<div class="loading">Loading detail…</div>';
+  applyModalRig();
+  try{
+    const r=await fetch(`${API}/api/permit/${encodeURIComponent(nbr)}`,AUTH);
+    if(!r.ok) throw new Error('HTTP '+r.status);
+    renderDetail(await r.json());
+  }catch(e){
+    $('#mBody').innerHTML=`<div class="empty">Couldn't load detail — ${esc(e.message)}</div>`;
+  }
+}
+function closeDetail(){ modal.classList.remove('open'); scrim.classList.remove('open'); }
+scrim.onclick=closeDetail; $('#mClose').onclick=closeDetail;
+document.addEventListener('keydown',e=>{ if(e.key==='Escape') closeDetail(); });
+
+function kvFilter(k,v,text){ // clickable value that applies a filter and closes modal
+  return `<span class="flt" data-fk="${k}" data-fv="${esc(v)}">${esc(text!=null?text:v)}</span>`;
+}
+function renderDetail(d){
+  $('#mAddr').textContent = d.primary_address || d.permit_nbr;
+  $('#mSub').innerHTML = `${esc(d.permit_nbr)} &middot; ${esc(d.era||'')}`;
+  const val=money(d.valuation);
+  const gmap = (d.lat&&d.lon)?`https://www.google.com/maps/search/?api=1&query=${d.lat},${d.lon}`:null;
+  const rows=[
+    ['Status', `<span class="pill ${statusClass(d.status_desc)}">${esc(d.status_desc||'—')}</span>`],
+    ['Issue date', fmtDate(d.issue_date)],
+    ['Work type', d.permit_type?kvFilter('work_type',d.permit_type):'—'],
+    ['Building type', d.permit_sub_type?kvFilter('building_type',d.permit_sub_type):'—'],
+    ['Permit group', esc(d.permit_group||'—')],
+    ['Use', esc(d.use_desc||'—')],
+    ['Council district', d.council_district?kvFilter('cd',d.council_district,'CD '+d.council_district):'—'],
+    ['ZIP', d.zip_code?kvFilter('zip',d.zip_code):'—'],
+    ['APN / parcel', esc(d.apn||'—')],
+  ];
+  const parcel=[];
+  if(d.parcel_assessed) parcel.push(['Assessed value', money(d.parcel_assessed)||'—']);
+  if(d.parcel_year_built && Number(d.parcel_year_built)>0) parcel.push(['Year built', esc(d.parcel_year_built)]);
+  if(d.parcel_sqft && Number(d.parcel_sqft)>0) parcel.push(['Living area', nfmt.format(Number(d.parcel_sqft))+' sqft']);
+  if(d.parcel_use) parcel.push(['Parcel use', esc(d.parcel_use)]);
+  if(d.parcel_address) parcel.push(['Parcel address', esc(d.parcel_address)]);
+
+  let html = `
+    <div class="m-val">${val?`<span class="big">${val}</span><span style="color:var(--ink-faint)">project valuation</span>`:`<span class="big" style="font-size:18px;color:var(--ink-faint)">No stated valuation</span>`}</div>
+    <dl class="kv">${rows.map(([k,v])=>`<dt>${k}</dt><dd>${v}</dd>`).join('')}</dl>`;
+  if(d.work_desc){ html+=`<div class="sect">Scope of work</div><div class="desc">${esc(d.work_desc)}</div>`; }
+  if(parcel.length){ html+=`<div class="sect">Parcel enrichment (Assessor 2025)</div><dl class="kv">${parcel.map(([k,v])=>`<dt>${k}</dt><dd>${v}</dd>`).join('')}</dl>`; }
+  html+=`<div class="m-actions">
+      ${gmap?`<a class="m-btn primary" href="${gmap}" target="_blank" rel="noopener noreferrer">📍 Open in Google Maps</a>`:''}
+      ${d.source_url?`<a class="m-btn" href="${esc(d.source_url)}" target="_blank" rel="noopener noreferrer">Source record ↗</a>`:''}
+      <button class="m-btn" id="rawToggle">{ } Raw JSON</button>
+    </div>
+    <pre class="raw" id="rawBox">${esc(JSON.stringify(d.raw||d,null,2))}</pre>`;
+  const body=$('#mBody'); body.innerHTML=html;
+  body.querySelector('#rawToggle').onclick=e=>{ $('#rawBox').classList.toggle('open'); e.currentTarget.blur(); };
+  body.querySelectorAll('.flt').forEach(s=>s.onclick=()=>{ setFilter(s.dataset.fk, s.dataset.fv); closeDetail(); });
+}
+
+// ---- modal rig: drag + resize + persist ----
+function applyModalRig(){
+  try{ const s=JSON.parse(localStorage.getItem('lap.modal'));
+    if(s){ modal.style.left=s.left; modal.style.top=s.top; modal.style.transform='none';
+      if(s.w) modal.style.width=s.w; if(s.h) modal.style.height=s.h; } }catch(e){}
+}
+(function rig(){
+  const hd=$('#mHd'), rz=$('#mRz'); let d=null;
+  const save=()=>localStorage.setItem('lap.modal',JSON.stringify({
+    left:modal.style.left,top:modal.style.top,w:modal.style.width,h:modal.style.height}));
+  hd.addEventListener('mousedown',e=>{ if(e.target.closest('.x'))return;
+    const r=modal.getBoundingClientRect(); modal.style.transform='none'; modal.style.left=r.left+'px'; modal.style.top=r.top+'px';
+    d={type:'move',x:e.clientX,y:e.clientY,l:r.left,t:r.top}; e.preventDefault(); });
+  rz.addEventListener('mousedown',e=>{ const r=modal.getBoundingClientRect();
+    d={type:'size',x:e.clientX,y:e.clientY,w:r.width,h:r.height}; e.preventDefault(); e.stopPropagation(); });
+  window.addEventListener('mousemove',e=>{ if(!d)return;
+    if(d.type==='move'){ modal.style.left=(d.l+e.clientX-d.x)+'px'; modal.style.top=Math.max(0,d.t+e.clientY-d.y)+'px'; }
+    else { modal.style.width=Math.max(340,d.w+e.clientX-d.x)+'px'; modal.style.height=Math.max(240,d.h+e.clientY-d.y)+'px'; } });
+  window.addEventListener('mouseup',()=>{ if(d){ save(); d=null; } });
+})();
+
+// ---- top-bar wiring ----
+let qTimer;
+$('#q').addEventListener('input',e=>{ clearTimeout(qTimer); qTimer=setTimeout(()=>{ state.q=e.target.value.trim(); reloadAll(); },300); });
+$('#sort').addEventListener('change',e=>{ state.sort=e.target.value; LS.sort=e.target.value; loadPermits(true); });
+$('#dens').addEventListener('input',e=>{ document.documentElement.style.setProperty('--cols',e.target.value); LS.dens=e.target.value; });
+let mvTimer;
+$('#minval').addEventListener('input',e=>{ clearTimeout(mvTimer); mvTimer=setTimeout(()=>{ state.min_value=e.target.value?String(e.target.value):''; reloadAll(); },350); });
+$('#since').addEventListener('change',e=>{ state.since=e.target.value||''; reloadAll(); });
+$('#loadmore').addEventListener('click',()=>{ state.page++; loadPermits(false); });
+
+// ---- boot ----
+$('#sort').value=state.sort;
+$('#dens').value=LS.dens; document.documentElement.style.setProperty('--cols',LS.dens);
+reloadAll();
+</script>
+</body>
+</html>
diff --git a/viewer/server.js b/viewer/server.js
new file mode 100644
index 0000000..4c4a1ed
--- /dev/null
+++ b/viewer/server.js
@@ -0,0 +1,140 @@
+import express from 'express';
+import { fileURLToPath } from 'url';
+import path from 'path';
+import pg from 'pg';
+
+const { Pool } = pg;
+const pool = process.env.DATABASE_URL
+  ? new Pool({ connectionString: process.env.DATABASE_URL })
+  : new Pool({ database: process.env.REALESTATE_DB || 'realestate' });
+
+const app = express();
+const PORT = process.env.PERMIT_VIEWER_PORT || 9814;
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+// --- Basic auth (admin / DW2024!) ---
+const USER = process.env.VIEWER_USER || 'admin';
+const PASS = process.env.VIEWER_PASS || 'DW2024!';
+app.use((req, res, next) => {
+  const hdr = req.headers.authorization || '';
+  const [, b64] = hdr.split(' ');
+  const [u, p] = Buffer.from(b64 || '', 'base64').toString().split(':');
+  if (u === USER && p === PASS) return next();
+  res.set('WWW-Authenticate', 'Basic realm="LA Building Permits"').status(401).send('Auth required');
+});
+
+app.use(express.static(path.join(__dirname, 'public')));
+
+// Building permits only (the 3 Bldg-* datasets; trade permits excluded).
+const BASE = `p.dataset_id IN ('pi9x-tg5x','dyxf-7hc4','e67z-kt2n')`;
+const ERA = `CASE p.dataset_id WHEN 'pi9x-tg5x' THEN '2020-present' WHEN 'dyxf-7hc4' THEN '2010-2019' WHEN 'e67z-kt2n' THEN 'pre-2010' END`;
+
+// Build a parameterized WHERE from query filters. Returns { sql, params }.
+function buildWhere(qp, params = []) {
+  const w = [BASE];
+  const add = (v) => { params.push(v); return `$${params.length}`; };
+  if (qp.q) w.push(`p.primary_address ILIKE ${add('%' + qp.q + '%')}`);
+  if (qp.era) {
+    const map = { '2020-present': 'pi9x-tg5x', '2010-2019': 'dyxf-7hc4', 'pre-2010': 'e67z-kt2n' };
+    if (map[qp.era]) w.push(`p.dataset_id = ${add(map[qp.era])}`);
+  }
+  if (qp.work_type) w.push(`p.permit_type = ${add(qp.work_type)}`);
+  if (qp.building_type) w.push(`p.permit_sub_type = ${add(qp.building_type)}`);
+  if (qp.status) w.push(`p.status_desc = ${add(qp.status)}`);
+  if (qp.cd) w.push(`p.council_district = ${add(qp.cd)}`);
+  if (qp.zip) w.push(`p.zip_code = ${add(qp.zip)}`);
+  if (qp.min_value) w.push(`p.valuation >= ${add(Number(qp.min_value))}`);
+  if (qp.since) w.push(`p.issue_date >= ${add(qp.since)}`);
+  return { sql: w.join(' AND '), params };
+}
+
+// Named sort presets (back-compat).
+const SORTS = {
+  newest: 'p.issue_date DESC NULLS LAST',
+  oldest: 'p.issue_date ASC NULLS LAST',
+  value_desc: 'p.valuation DESC NULLS LAST',
+  value_asc: 'p.valuation ASC NULLS LAST',
+  address: 'p.primary_address ASC',
+};
+// Per-column sort whitelist (col name -> SQL expr). Enables sort=<col>&dir=asc|desc.
+const COLS = {
+  lead_score: 'permit_lead_score(p.issue_date,p.valuation,p.permit_type,p.permit_sub_type,p.status_desc)',
+  issued: 'p.issue_date', address: 'p.primary_address', zip: 'p.zip_code',
+  cd: 'p.council_district', work_type: 'p.permit_type', building_type: 'p.permit_sub_type',
+  use_desc: 'p.use_desc', status: 'p.status_desc', project_value: 'p.valuation',
+  apn: 'p.apn', assessed_value: 'a.total_value', year_built: 'a.year_built',
+  sqft: 'a.sqft_main', era: 'p.dataset_id', permit_nbr: 'p.permit_nbr',
+};
+function orderBy(sort, dir) {
+  if (COLS[sort]) return `${COLS[sort]} ${dir === 'asc' ? 'ASC' : 'DESC'} NULLS LAST`;
+  return SORTS[sort] || 'permit_lead_score(p.issue_date,p.valuation,p.permit_type,p.permit_sub_type,p.status_desc) DESC NULLS LAST';
+}
+
+// --- Paginated, filtered, sorted permits ---
+app.get('/api/permits', async (req, res) => {
+  try {
+    const limit = Math.min(Number(req.query.limit) || 60, 200);
+    const page = Math.max(Number(req.query.page) || 1, 1);
+    const offset = (page - 1) * limit;
+    const order = orderBy(req.query.sort, req.query.dir);
+
+    const { sql: where, params } = buildWhere(req.query);
+    const rowsSql = `
+      SELECT permit_lead_score(p.issue_date,p.valuation,p.permit_type,p.permit_sub_type,p.status_desc) AS lead_score,
+             ${ERA} AS era, p.permit_nbr, p.issue_date, p.primary_address AS address, p.zip_code AS zip,
+             p.council_district AS cd, p.permit_type AS work_type, p.permit_sub_type AS building_type,
+             p.use_desc, p.status_desc AS status, p.valuation AS project_value, p.apn,
+             a.total_value AS assessed_value, a.year_built, a.sqft_main AS sqft, p.lat, p.lon
+      FROM la_building_permits_raw p
+      LEFT JOIN la_assessor_parcels_raw a ON a.ain = p.apn AND a.roll_year = '2025'
+      WHERE ${where}
+      ORDER BY ${order}
+      LIMIT ${limit} OFFSET ${offset}`;
+    const rows = (await pool.query(rowsSql, params)).rows;
+
+    // total (exact when filtered; cached constant when unfiltered for speed)
+    const isFiltered = Object.keys(req.query).some((k) => ['q','era','work_type','building_type','status','cd','zip','min_value','since'].includes(k) && req.query[k]);
+    let total;
+    if (isFiltered) {
+      total = Number((await pool.query(`SELECT count(*) c FROM la_building_permits_raw p WHERE ${where}`, params)).rows[0].c);
+    } else {
+      total = 1578997;
+    }
+    res.json({ total, page, limit, rows });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+// --- Facet counts (respect current filters) ---
+app.get('/api/facets', async (req, res) => {
+  try {
+    const facet = async (expr) => {
+      const { sql: where, params } = buildWhere(req.query);
+      const q = `SELECT ${expr} AS v, count(*) c FROM la_building_permits_raw p WHERE ${where} AND ${expr} IS NOT NULL GROUP BY 1 ORDER BY c DESC`;
+      return (await pool.query(q, params)).rows;
+    };
+    const [era, work_type, building_type, status, cd, zip] = await Promise.all([
+      facet(ERA), facet('p.permit_type'), facet('p.permit_sub_type'),
+      facet('p.status_desc'), facet('p.council_district'),
+      (async () => { const { sql: where, params } = buildWhere(req.query);
+        return (await pool.query(`SELECT p.zip_code v, count(*) c FROM la_building_permits_raw p WHERE ${where} AND p.zip_code IS NOT NULL GROUP BY 1 ORDER BY c DESC LIMIT 40`, params)).rows; })(),
+    ]);
+    res.json({ era, work_type, building_type, status, cd, zip });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+// --- Single permit detail ---
+app.get('/api/permit/:nbr', async (req, res) => {
+  try {
+    const q = `
+      SELECT ${ERA} AS era, p.*, a.total_value AS parcel_assessed, a.year_built AS parcel_year_built,
+             a.sqft_main AS parcel_sqft, a.use_type AS parcel_use, a.property_location AS parcel_address
+      FROM la_building_permits_raw p
+      LEFT JOIN la_assessor_parcels_raw a ON a.ain = p.apn AND a.roll_year = '2025'
+      WHERE ${BASE} AND p.permit_nbr = $1 LIMIT 1`;
+    const row = (await pool.query(q, [req.params.nbr])).rows[0];
+    if (!row) return res.status(404).json({ error: 'not found' });
+    res.json(row);
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.listen(PORT, () => console.log(`LA Building Permits viewer → http://127.0.0.1:${PORT} (admin/${PASS})`));

← ed10986 Add DTD-driven Opportunity Board (board.html)  ·  back to La Socrata Ingester  ·  Viewer: sortable+movable-column table + permit_lead_score ra f47742c →