← back to Commercialrealestate

public/lending-grid.js

306 lines

/* lending-grid.js — reusable CRCP data-grid engine for the lending-intelligence layer.
 *
 * One engine, config-driven, so every lending dataset (FDIC lenders, NCUA credit unions,
 * HMDA market activity, SBA lenders, loan officers) gets the IDENTICAL proven CRCP grid UX:
 *   • search ALL fields (space = AND)   • sort <select> + density slider (grid view)
 *   • resizable + drag-reorder + show/hide columns (col-resize.js)   • START-TIGHT defaults
 *   • left facet rail with click-to-filter chips + counts   • CSV export of visible rows
 *   • evidence-first: a Source column + a per-row evidence/source modal   • localStorage persistence
 *
 * Generalized from public/broker-grid.html (the reference list build, Steve rule 2026-07-31).
 * Drive it by defining window.GRID_CONFIG then including this file. See public/lenders.html.
 */
(function () {
  const CFG = window.GRID_CONFIG;
  if (!CFG) { console.error('lending-grid: window.GRID_CONFIG missing'); return; }
  const NS = CFG.ns || 'grid';                       // localStorage namespace
  const LS = k => NS + ':' + k;
  const $ = s => document.querySelector(s);
  const esc = s => (s == null ? '' : String(s)).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
  const num = n => (n == null || n === '') ? '—' : Number(n).toLocaleString();
  const money = n => (n == null || n === '') ? '—' : '$' + Number(n).toLocaleString();
  const money000 = n => (n == null || n === '') ? '—' : '$' + Number(n * 1000).toLocaleString(); // FDIC $thousands
  const pct = n => (n == null || n === '') ? '—' : Number(n).toFixed(2) + '%';
  const tel = v => v ? `<a href="tel:${esc(v)}">${esc(v)}</a>` : '<span class="miss">—</span>';
  const mail = v => v ? `<a href="mailto:${esc(v)}">${esc(v)}</a>` : '<span class="miss">—</span>';
  const siteName = u => { try { return new URL(/^https?:/.test(u) ? u : 'https://' + u).hostname.replace(/^www\./, ''); } catch (e) { return u; } };
  const web = v => { 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" title="${esc(h)}">${esc(siteName(v))}↗</a>`; };
  const link = (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">${esc(lbl || 'source↗')}</a>`; };
  const dt = s => { if (!s) return '—'; try { return new Date(s).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); } catch (e) { return '—'; } };

  const COLS = CFG.cols.slice();
  const DEFAULT_ON = new Set(CFG.defaultCols || COLS.slice(0, 6).map(c => c.k));
  const idKey = CFG.idKey || 'id';

  let DATA = [], view = 'table', q = '', lastColSig = '';
  let sortKey = CFG.sortKey || COLS[0].k;
  let sortDir = CFG.sortDir === 1 ? 1 : -1;
  const FAC = {};                                    // active facet selections {field: Set}
  (CFG.facets || []).forEach(f => FAC[f.k] = new Set());

  let VISCOL = (() => { try { return JSON.parse(localStorage.getItem(LS('cols')) || '{}'); } catch (e) { return {}; } })();
  const colVis = k => (k in VISCOL) ? !!VISCOL[k] : DEFAULT_ON.has(k);
  let COLORDER = (() => { try { const a = JSON.parse(localStorage.getItem(LS('order')) || '[]'); return Array.isArray(a) ? a : []; } catch (e) { return []; } })();
  function syncColOrder() { const keys = COLS.map(c => c.k); COLORDER = COLORDER.filter(k => keys.includes(k)); keys.forEach(k => { if (!COLORDER.includes(k)) COLORDER.push(k); }); }
  function orderedCols() { syncColOrder(); return COLORDER.map(k => COLS.find(c => c.k === k)).filter(Boolean); }
  function visCols() { return orderedCols().filter(c => colVis(c.k)); }
  const saveCols = () => { try { localStorage.setItem(LS('cols'), JSON.stringify(VISCOL)); } catch (e) {} };
  const saveColOrder = () => { try { localStorage.setItem(LS('order'), JSON.stringify(COLORDER)); } catch (e) {} };
  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();
  }
  const tog = (set, v) => { set.has(v) ? set.delete(v) : set.add(v); };

  // Auto-discover any API field not already declared as a column (so nothing is hidden from the user).
  function autoCols() {
    const known = new Set(COLS.map(c => c.k)); const skip = new Set([idKey, '__hay']); const seen = new Set();
    DATA.slice(0, 80).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 || 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, 22), t: isNum ? 'n' : 's', g: 'Other', def: 0 });
    }));
  }

  function fmt(r, c) {
    const v = r[c.k];
    switch (c.t) {
      case 'n': return num(v);
      case 'money': return money(v);
      case 'money000': return money000(v);
      case 'pct': return pct(v);
      case 'tel': return tel(v);
      case 'mail': return mail(v);
      case 'web': return web(v);
      case 'date': return dt(v);
      case 'src': return link(r.source_url, r.source || 'source');
      case 'bool': return v ? '<span class="geo ca">✓</span>' : '<span class="miss">—</span>';
      case 'badge': {
        if (v == null || v === '') return '<span class="miss">—</span>';
        const cls = (CFG.badgeClass && CFG.badgeClass(c.k, v)) || 'com';
        return `<span class="atype ${cls}">${esc(v)}</span>`;
      }
      default: {
        const s = (v == null || v === '') ? '—' : String(v);
        return (c.max && s.length > c.max) ? `<span title="${esc(s)}">${esc(s.slice(0, c.max))}…</span>` : esc(s);
      }
    }
  }

  function passFacets(r) {
    for (const f of (CFG.facets || [])) {
      const set = FAC[f.k]; if (!set.size) continue;
      const val = f.derive ? f.derive(r) : r[f.k];
      if (!set.has(val == null ? '' : String(val))) return false;
    }
    return true;
  }
  function filtered() {
    let rows = DATA.filter(passFacets);
    if (q) {
      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 = a[c.k], y = b[c.k];
      const xE = (x == null || x === ''), yE = (y == null || y === '');
      if (xE && yE) return 0; if (xE) return 1; if (yE) return -1;
      const xn = !isNaN(+x), yn = !isNaN(+y);
      if (xn && yn) { x = +x; y = +y; } else { x = String(x).toLowerCase(); y = String(y).toLowerCase(); }
      if (x < y) return -sortDir; if (x > y) return sortDir; return 0;
    });
    return rows;
  }

  function buildRail() {
    const host = $('#railFacets');
    if (host) {
      host.innerHTML = (CFG.facets || []).map(f => `<div class="rsec collapsed"><h4>${esc(f.l)}</h4><div class="chips" data-facet="${esc(f.k)}"></div></div>`).join('');
      (CFG.facets || []).forEach(f => {
        const counts = {};
        DATA.forEach(r => { const v = f.derive ? f.derive(r) : r[f.k]; if (v == null || v === '') return; counts[v] = (counts[v] || 0) + 1; });
        const el = host.querySelector(`[data-facet="${CSS.escape(f.k)}"]`);
        el.innerHTML = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, f.limit || 18)
          .map(([k, c]) => `<span class="chip" data-v="${esc(k)}">${esc(f.label ? f.label(k) : 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-10128), as a VIEW over this engine's own VISCOL/COLORDER closure state.
  let __cm = null;
  function buildFieldToggles() {
    if (!window.ColumnManager) {  // fallback if the module hasn't loaded (keeps the grid usable)
      const groups = {}; COLS.forEach(c => { (groups[c.g || 'Other'] = groups[c.g || 'Other'] || []).push(c); });
      $('#fFields').innerHTML = Object.entries(groups).map(([g, cs]) =>
        `<div class="fgrp">${esc(g)}</div>` + cs.map(c => `<label class="ftog"><input type="checkbox" data-ck="${c.k}" ${colVis(c.k) ? 'checked' : ''}><span>${esc(c.l)}</span></label>`).join('')).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 render() {
    const rows = filtered(); const cols = visCols();
    $('#count').textContent = `${rows.length.toLocaleString()} of ${DATA.length.toLocaleString()} ${CFG.noun || 'rows'}`;
    $('#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 => `<tr class="brow" data-id="${esc(r[idKey])}" style="cursor:pointer">` + cols.map(c => { const cls = (['n', 'money', 'money000', 'pct'].includes(c.t)) ? ' class="n"' : ''; return `<td${cls}>${fmt(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'));
    } else if (view === 'list' && lv) {
      // compact list — one scannable row per record, every visible field as inline key/value
      const nameKey = CFG.nameKey || cols[0].k;
      const meta = cols.filter(c => c.k !== nameKey);
      lv.innerHTML = rows.map(r => `<div class="lrow brow" data-id="${esc(r[idKey])}" style="cursor:pointer"><div class="lnm">${esc(r[nameKey])}</div><div class="lmeta">`
        + meta.map(c => `<span class="lm"><span class="lk">${esc(c.l)}</span>${fmt(r, c)}</span>`).join('') + `</div></div>`).join('');
    } else {
      const nameKey = CFG.nameKey || cols[0].k;
      const bodyCols = cols.filter(c => c.k !== nameKey);
      $('#gridView').innerHTML = rows.map(r => `<div class="gc brow" data-id="${esc(r[idKey])}" style="cursor:pointer"><div class="nm">${esc(r[nameKey])}</div>`
        + bodyCols.map(c => `<div class="row"><span class="k">${esc(c.l)}</span><span>${fmt(r, c)}</span></div>`).join('') + `</div>`).join('');
    }
  }

  // Evidence/source modal — package §16 evidence-first. Shows every field + its provenance.
  function openRow(id) {
    const r = DATA.find(x => String(x[idKey]) === String(id)); if (!r) return;
    const ov = $('#ov'), mb = $('#mbody'); ov.classList.add('on');
    const nameKey = CFG.nameKey || COLS[0].k;
    // Optional per-page rich detail (e.g. firms.html drills a firm → its broker roster + listings
    // via /api/firm). Async-safe + backward-compatible: pages without CFG.detail keep the generic
    // field/evidence dump below. Helpers are passed so the page needn't re-implement formatters.
    if (typeof CFG.detail === 'function') {
      mb.innerHTML = `<h3>${esc(r[nameKey])}</h3><div class="mfirm">${esc(CFG.title || '')}</div><div class="sec" style="opacity:.6">Loading…</div>`;
      Promise.resolve(CFG.detail(r, { esc, money, money000, num, pct, dt, link, siteName })).then(html => {
        if (typeof html === 'string' && ov.classList.contains('on')) mb.innerHTML = html;
      }).catch(err => { mb.innerHTML = `<h3>${esc(r[nameKey])}</h3><div class="sec">Detail unavailable — ${esc(String((err && err.message) || err))}</div>`; });
      return;
    }
    const rows = COLS.filter(c => r[c.k] != null && r[c.k] !== '' && c.k !== nameKey)
      .map(c => `<div class="litem"><span class="m">${esc(c.l)}</span><span>${fmt(r, c)}</span></div>`).join('');
    const ev = r.source_url
      ? `<div class="sec"><h4>Evidence & source</h4><div class="litem"><span class="m">Source</span><span>${esc(r.source || '—')}</span></div>`
        + (r.identifier ? `<div class="litem"><span class="m">Identifier</span><span>${esc(r.identifier)}</span></div>` : '')
        + `<div class="litem"><span class="m">Public record</span><span>${link(r.source_url, siteName(r.source_url) + '↗')}</span></div>`
        + (r.retrieved_at ? `<div class="litem"><span class="m">Retrieved</span><span>${dt(r.retrieved_at)}</span></div>` : '')
        + `</div>` : '';
    mb.innerHTML = `<h3>${esc(r[nameKey])}</h3><div class="mfirm">${esc(CFG.title || '')}</div><div class="sec">${rows}</div>${ev}`;
  }

  function exportCSV() {
    const rows = filtered(); const cols = visCols();
    const cv = (r, c) => { let v = r[c.k]; 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 = (CFG.ns || 'crcp') + '-' + rows.length + '.csv'; a.click(); URL.revokeObjectURL(a.href);
  }

  // ── wiring ──
  function buildSortSel() {
    const sel = $('#sortsel'); 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;
  }
  // ── grid/list/table 3-view + mobile filter modal (engine-owned so all lending pages
  //    inherit them from a single edit; the desktop facet rail already collapses via .rsec.collapsed) ──
  function setupViews() {
    const vt = $('#view');
    if (vt) vt.innerHTML = '<button data-v="grid">▦ Grid</button><button data-v="list">☰ List</button><button data-v="table">▤ Table</button>';
    if (!$('#listView')) {
      const tv = $('#tableView'), host = tv ? tv.parentNode : ($('#gridView') && $('#gridView').parentNode);
      if (host) { const d = document.createElement('div'); d.id = 'listView'; d.className = 'lst hide'; host.appendChild(d); }
    }
    setupMobileFilters();
  }
  function setupMobileFilters() {
    const rail = document.querySelector('.rail'); if (!rail) return;
    const hdr = document.querySelector('header'); if (!hdr) return;
    if ($('#railBtn')) return;                                   // idempotent
    const home = document.createComment('lg-rail-home'); rail.parentNode.insertBefore(home, rail);
    const btn = document.createElement('button'); btn.id = 'railBtn'; btn.type = 'button'; btn.className = 'railbtn'; btn.innerHTML = '⚙ Filters';
    const anchor = $('#view'); if (anchor) hdr.insertBefore(btn, anchor); else hdr.appendChild(btn);
    const ov = document.createElement('div'); ov.id = 'railOv'; ov.className = 'railov';
    const md = document.createElement('div'); md.className = 'railmd';
    const hd = document.createElement('div'); hd.className = 'railmd-h'; hd.innerHTML = '<span>Filters</span><button type="button" class="railmd-x" aria-label="Close">✕</button>';
    const bd = document.createElement('div'); bd.className = 'railmd-b';
    md.appendChild(hd); md.appendChild(bd); ov.appendChild(md); document.body.appendChild(ov);
    const open = () => ov.classList.add('on'), close = () => ov.classList.remove('on');
    btn.addEventListener('click', open);
    hd.querySelector('.railmd-x').addEventListener('click', close);
    ov.addEventListener('click', e => { if (e.target === ov) close(); });
    document.addEventListener('keydown', e => { if (e.key === 'Escape') close(); });
    const 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();
  }

  function initControls() {
    setupViews();
    const sel = $('#sortsel'), dz = $('#density');
    buildSortSel();
    const sk = localStorage.getItem(LS('sortKey')), sd = localStorage.getItem(LS('sortDir'));
    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 = COLS[0].k;
    sel.value = sortKey;
    sel.addEventListener('change', () => { sortKey = sel.value; const c = COLS.find(x => x.k === sortKey); sortDir = (c && ['n', 'money', 'money000', 'pct', 'date'].includes(c.t)) ? -1 : 1; localStorage.setItem(LS('sortKey'), sortKey); localStorage.setItem(LS('sortDir'), 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(LS('density')), 10), dval = (dv >= 1 && dv <= 10) ? dv : 5;
    dz.value = dval; applyDensity(dval);
    dz.addEventListener('input', () => { const v = +dz.value; applyDensity(v); localStorage.setItem(LS('density'), v); });
    const pv = localStorage.getItem(LS('view')); 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';
  }

  $('#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 && ['n', 'money', 'money000', 'pct', 'date'].includes(c.t)) ? -1 : 1; } localStorage.setItem(LS('sortKey'), sortKey); localStorage.setItem(LS('sortDir'), sortDir); const sel = $('#sortsel'); if (sel && Array.from(sel.options).some(o => o.value === sortKey)) sel.value = sortKey; render(); });
  // drag-to-reorder columns
  let dragKey = null;
  $('#thead').addEventListener('dragstart', e => { const th = e.target.closest('th'); if (!th) return; if (document.body.classList.contains('cr-dragging') || e.target.closest('.cr-handle')) { e.preventDefault(); return; } 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(); });
  $('#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(LS('view'), view); $('#denswrap').style.display = (view === 'grid') ? 'inline-flex' : 'none'; 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(); });
  $('#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(); });
  document.addEventListener('click', e => { const chip = e.target.closest('.chip[data-v]'); if (chip) { const host = chip.closest('[data-facet]'); if (host) { tog(FAC[host.dataset.facet], chip.dataset.v); chip.classList.toggle('active'); render(); return; } } const row = e.target.closest('.brow'); if (row && e.target.tagName !== 'A' && e.target.tagName !== 'BUTTON') { openRow(row.dataset.id); } if (e.target.id === 'ov') $('#ov').classList.remove('on'); });
  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'); }); }

  initControls();
  fetch(CFG.api).then(r => r.json()).then(d => {
    DATA = (CFG.dataKey ? d[CFG.dataKey] : (d.rows || d.data)) || [];
    if (typeof CFG.rowOk === 'function') DATA = DATA.filter(CFG.rowOk);   // drop junk/placeholder rows before they clutter a sort
    if (CFG.demo != null || d.meta) { const dm = $('#demoNote'); if (dm && d.meta) dm.textContent = `${d.meta.source || ''} · ${DATA.length.toLocaleString()} records · retrieved ${dt(d.meta.retrieved_at)}`; }
    autoCols(); buildSortSel(); buildRail(); render();
  }).catch(() => { $('#count').textContent = 'failed to load ' + CFG.api; });
})();