← back to Ca Donations

public/index.html

227 lines

<!doctype html>
<html lang="en">
<head>
<script>
// Credential-safe fetch guard (fleet drop-in) — if this page is opened with
// credentials in the URL (a saved bookmark / Chrome-remembered basic-auth:
// https://user:pass@host/…), the browser poisons document.baseURI, so any
// relative or root-relative fetch('/api/…') throws "Request cannot be constructed
// from a URL that includes credentials" and callers silently fall to an empty
// state. Resolve every non-absolute request URL against the credential-free
// location instead of baseURI. Placed first so it wraps window.fetch before any
// app script runs. Ref: creds-in-url-fetch-guard-fleet-pattern.
(function () {
  var _fetch = window.fetch.bind(window);
  var cleanBase = function () { return location.origin + location.pathname; };
  window.fetch = function (input, init) {
    try {
      if (typeof input === 'string' && !/^[a-z]+:\/\//i.test(input) && input.indexOf('//') !== 0) {
        input = new URL(input, cleanBase()).href;
      } else if (input instanceof Request && !/^[a-z]+:\/\//i.test(input.url)) {
        input = new Request(new URL(input.url, cleanBase()).href, input);
      }
    } catch (_) { /* fall through to native */ }
    return _fetch(input, init);
  };
})();
</script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CA Donations — Public Records</title>
<style>
  :root { --cols: 4; --fs: 13px; }
  * { box-sizing: border-box; }
  body { margin: 0; font: var(--fs)/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; color: #1a1a1a; background: #f6f7f9; }
  header { padding: 14px 18px; background: #0f2540; color: #fff; }
  header h1 { margin: 0; font-size: 17px; font-weight: 600; letter-spacing: .2px; }
  header .sub { opacity: .8; font-size: 12px; margin-top: 3px; }
  .stats { display: flex; gap: 16px; padding: 8px 18px; background: #16324f; color: #cfe0f5; font-size: 12px; flex-wrap: wrap; }
  .stats b { color: #fff; }
  .tabs { display: flex; gap: 4px; padding: 8px 18px 0; background: #16324f; }
  .tabs button { border: 0; padding: 8px 14px; border-radius: 6px 6px 0 0; background: #23456b; color: #cfe0f5; cursor: pointer; font-size: 13px; }
  .tabs button.active { background: #f6f7f9; color: #0f2540; font-weight: 600; }
  .controls { display: flex; gap: 10px; align-items: center; padding: 12px 18px; background: #fff; border-bottom: 1px solid #e3e7ec; flex-wrap: wrap; position: sticky; top: 0; z-index: 5; }
  .controls input[type=text], .controls select { padding: 7px 9px; border: 1px solid #cbd2da; border-radius: 6px; font-size: 13px; }
  .controls input[type=text] { min-width: 240px; }
  .controls label { font-size: 12px; color: #556; display: flex; align-items: center; gap: 6px; }
  .grid { display: grid; grid-template-columns: repeat(var(--cols), 1fr); gap: 10px; padding: 16px 18px; }
  .card { background: #fff; border: 1px solid #e3e7ec; border-radius: 8px; padding: 10px 12px; }
  .card .name { font-weight: 600; font-size: calc(var(--fs) + 1px); margin-bottom: 5px; }
  .kv { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 5px; }
  .chip { background: #eef2f7; border: 1px solid #dbe2ea; border-radius: 5px; padding: 2px 7px; font-size: 11px; cursor: pointer; }
  .chip:hover { background: #dce7f5; }
  .chip .lbl { color: #7a8797; text-transform: uppercase; font-size: 9px; letter-spacing: .4px; margin-right: 4px; }
  .amt { color: #0a7d33; font-weight: 600; }
  .status-may-operate { color: #0a7d33; }
  .status-may-not-operate, .status-not-operating { color: #b4232a; }
  .empty { padding: 40px 18px; color: #667; text-align: center; }
  .pending { background: #fff6e0; border: 1px solid #f0d98a; color: #7a5c00; border-radius: 8px; padding: 16px; margin: 18px; }
  .banner { display: none; padding: 9px 18px; background: #eaf1fb; color: #234; border-bottom: 1px solid #cfe0f5; font-size: 12px; }
  .banner.show { display: block; }
  .note { font-size: 11px; color: #7a8797; padding: 0 18px 16px; }
  .note a { color: #16324f; }
</style>
</head>
<body>
<header>
  <h1>California Donations — Public Records</h1>
  <div class="sub">Political (donor-level, above the $100 CA itemization threshold) + Charitable (org &amp; foundation-grant records). Individual public-charity donors are not public.</div>
</header>
<div class="banner" id="banner">Public records from CA SoS / IRS / CA AG. Charitable &amp; foundation data shown. Political donor-level data is access-controlled.</div>
<div class="stats" id="stats">loading…</div>
<div class="tabs">
  <button data-tab="orgs" class="active">Charitable Orgs</button>
  <button data-tab="grants">Foundation Grants</button>
  <button data-tab="political">Political Contributions</button>
</div>
<div class="controls">
  <input type="text" id="q" placeholder="Search…">
  <select id="filter"></select>
  <label>Sort
    <select id="sort"></select>
  </label>
  <label>Density
    <input type="range" id="density" min="2" max="7" step="1">
  </label>
</div>
<div id="view"></div>
<div class="note">Sources: CAL-ACCESS (state) · FEC bulk (federal) · local NetFile/Socrata · CA AG Registry · IRS 990/990-PF · ProPublica. All $0 public data. Every value is clickable to refine. · <a href="/takedown.html">Records &amp; removal requests</a></div>

<script>
const $ = (s) => document.querySelector(s);
const view = $('#view');
let tab = 'orgs';
const state = { orgs:{q:'',filter:'',sort:'name'}, grants:{q:'',filter:'',sort:'amount'}, political:{q:'',filter:'',sort:'date'} };

// Density persists (Steve's standing rule).
const density = $('#density');
density.value = localStorage.getItem('cad_density') || 4;
document.documentElement.style.setProperty('--cols', density.value);
density.oninput = () => { document.documentElement.style.setProperty('--cols', density.value); localStorage.setItem('cad_density', density.value); };

const SORTS = {
  orgs:      [['name','Name A→Z'],['status','Status'],['ntee','NTEE']],
  grants:    [['amount','Amount ↓'],['year','Year ↓'],['grantor','Grantor']],
  political: [['date','Date ↓'],['amount','Amount ↓'],['donor','Donor'],['recipient','Recipient']],
};
const FILTERS = {
  orgs:      [['','All statuses'],['may-operate','May operate'],['may-not-operate','May not operate'],['undetermined','Undetermined'],['not-operating','Not operating']],
  grants:    [['','All types'],['990pf_partxv','990-PF grants'],['990_sched_i','990 Sched I'],['990_sched_f','990 Sched F']],
  political: [['','All jurisdictions'],['state','State'],['federal','Federal'],['local','Local']],
};

function chip(lbl, val, onClick) {
  if (val == null || val === '') return '';
  const safe = String(val).replace(/</g,'&lt;');
  return `<a class="chip drill" data-filter="1" href="${hrefFor(onClick)}" data-click='${JSON.stringify(onClick)}'><span class="lbl">${lbl}</span>${safe}</a>`;
}
const money = (n) => n == null ? '' : '$' + Number(n).toLocaleString(undefined,{maximumFractionDigits:0});

// ── href-to-deeper-data primitives (page-native over tab+state) — HARD rule 2026-07-31 ──
// Every chip is a real, shareable href to its refined view (deep-link + back-button).
function qstr(){ const st=state[tab]; const u=new URLSearchParams(); u.set('tab',tab); if(st.q)u.set('q',st.q); if(st.filter)u.set('filter',st.filter); if(st.sort)u.set('sort',st.sort); return '?'+u.toString(); }
function hrefFor(o){ const t=(o&&o.t)||tab; const st={...state[t]}; if(o){ if(o.q!==undefined)st.q=o.q; if(o.filter!==undefined)st.filter=o.filter; if(o.filterNtee!==undefined)st.q=o.filterNtee; } const u=new URLSearchParams(); u.set('tab',t); if(st.q)u.set('q',st.q); if(st.filter)u.set('filter',st.filter); if(st.sort)u.set('sort',st.sort); return '?'+u.toString(); }
function readURL(){ const u=new URLSearchParams(location.search); const t=u.get('tab'); if(t&&state[t])tab=t; const st=state[tab]; if(u.has('q'))st.q=u.get('q'); if(u.has('filter'))st.filter=u.get('filter'); if(u.has('sort'))st.sort=u.get('sort'); }
function writeURL(push){ history[push?'pushState':'replaceState']({},'',qstr()); }
window.qstr=qstr; window.readURL=readURL; window.writeURL=writeURL; window.hrefFor=hrefFor;

// Public mode = /api/stats is reachable without creds but /api/political is not.
async function detectPublicMode() {
  try {
    const [stats, pol] = await Promise.all([
      fetch('/api/stats'), fetch('/api/political?limit=1'),
    ]);
    if (stats.ok && pol.status === 401) $('#banner').classList.add('show');
  } catch (_) {}
}

async function loadStats() {
  const s = await (await fetch('/api/stats')).json();
  $('#stats').innerHTML =
    `<span><b>${s.charitable_orgs.toLocaleString()}</b> charitable orgs</span>` +
    `<span><b>${s.charitable_grants.toLocaleString()}</b> foundation grants</span>` +
    `<span><b>${s.political_contributions.toLocaleString()}</b> political contributions</span>`;
}

function syncControls() {
  const st = state[tab];
  $('#q').value = st.q;
  $('#sort').innerHTML = SORTS[tab].map(([v,l]) => `<option value="${v}" ${v===st.sort?'selected':''}>${l}</option>`).join('');
  $('#filter').innerHTML = FILTERS[tab].map(([v,l]) => `<option value="${v}" ${v===st.filter?'selected':''}>${l}</option>`).join('');
}

async function render() {
  const st = state[tab];
  try { writeURL(false); } catch(_) {}
  let url, cards;
  if (tab === 'orgs') {
    url = `/api/orgs?q=${encodeURIComponent(st.q)}&status=${st.filter}&sort=${st.sort}&limit=200`;
    const { rows } = await (await fetch(url)).json();
    cards = rows.map(r => `<div class="card"><div class="name">${(r.name||'').replace(/</g,'&lt;')}</div>
      <div class="kv">
        ${chip('EIN', r.ein, {t:'orgs',q:r.ein})}
        ${chip('City', r.city, {t:'orgs',q:r.city})}
        ${r.ntee_code ? `<a class="chip drill" data-filter="1" href="${hrefFor({t:'orgs',filterNtee:r.ntee_code})}" data-click='${JSON.stringify({t:'orgs',filterNtee:r.ntee_code})}'><span class="lbl">NTEE</span>${r.ntee_code}</a>`:''}
        ${r.ca_ag_status ? `<a class="chip drill status-${r.ca_ag_status}" data-filter="1" href="${hrefFor({t:'orgs',filter:r.ca_ag_status})}" data-click='${JSON.stringify({t:'orgs',filter:r.ca_ag_status})}'><span class="lbl">CA AG</span>${r.ca_ag_status}</a>`:''}
        ${chip('Grants', 'view →', {t:'grants',q:r.name})}
      </div></div>`).join('');
    view.innerHTML = rows.length ? `<div class="grid">${cards}</div>` : `<div class="empty">No orgs match.</div>`;
  } else if (tab === 'grants') {
    url = `/api/grants?grantor=${encodeURIComponent(st.q)}&grantee=${encodeURIComponent(st.q)}&limit=200`;
    const { rows } = await (await fetch(`/api/grants?grantor=${encodeURIComponent(st.q)}&sort=${st.sort}&limit=200`)).json();
    if (!rows.length) { view.innerHTML = `<div class="pending"><b>Foundation-grant ingest pending.</b> Grant records (990-PF Part XV, 990 Schedule I/F) load from IRS 990 XML in the charitable-grants node. The schema, API, and grid are live and will populate on that run.</div>`; return; }
    cards = rows.map(r => `<div class="card"><div class="name">${(r.grantee_name||'').replace(/</g,'&lt;')} <span class="amt">${money(r.amount)}</span></div>
      <div class="kv">
        ${chip('Grantor', r.grantor_name, {t:'grants',q:r.grantor_name})}
        ${chip('City', r.grantee_city, {t:'grants',q:r.grantee_city})}
        ${chip('Year', r.tax_year, {t:'grants',q:''})}
        ${chip('Type', r.grant_type, {t:'grants',q:''})}
      </div></div>`).join('');
    view.innerHTML = `<div class="grid">${cards}</div>`;
  } else {
    const { rows } = await (await fetch(`/api/political?donor=${encodeURIComponent(st.q)}&jurisdiction=${st.filter}&sort=${st.sort}&limit=200`)).json();
    if (!rows.length) { view.innerHTML = `<div class="pending"><b>Political-contribution ingest pending.</b> Donor-level rows load from the CAL-ACCESS daily dump (state, incl. Forms 461/496/497), FEC bulk <code>itcont</code> filtered to CA (federal), and local NetFile/Socrata. The schema, API, and grid are live and will populate on that run.</div>`; return; }
    cards = rows.map(r => `<div class="card"><div class="name">${(r.donor_name||'').replace(/</g,'&lt;')} <span class="amt">${money(r.amount)}</span></div>
      <div class="kv">
        ${chip('Employer', r.donor_employer, {t:'political',q:r.donor_employer})}
        ${chip('City', r.donor_city, {t:'political',q:r.donor_city})}
        ${chip('→', r.recipient_name, {t:'political',q:r.recipient_name})}
        ${chip('Office', r.office, {t:'political',q:''})}
        ${chip('Juris', r.jurisdiction, {t:'political',filter:r.jurisdiction})}
        ${chip('Date', r.contribution_date, {t:'political',q:''})}
      </div></div>`).join('');
    view.innerHTML = `<div class="grid">${cards}</div>`;
  }
}

// href-drill: clicking any chip refines the query.
view.addEventListener('click', (e) => {
  const el = e.target.closest('.chip'); if (!el || !el.dataset.click) return;
  if (e.metaKey || e.ctrlKey) return; // let cmd/ctrl-click open the deep-link in a new tab
  e.preventDefault();
  const o = JSON.parse(el.dataset.click);
  if (o.t) tab = o.t;
  document.querySelectorAll('.tabs button').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
  const st = state[tab];
  if (o.q !== undefined) st.q = o.q;
  if (o.filter !== undefined) st.filter = o.filter;
  if (o.filterNtee !== undefined) st.q = o.filterNtee;
  syncControls(); render();
});

$('#q').oninput = (e) => { state[tab].q = e.target.value; render(); };
$('#sort').onchange = (e) => { state[tab].sort = e.target.value; render(); };
$('#filter').onchange = (e) => { state[tab].filter = e.target.value; render(); };
document.querySelectorAll('.tabs button').forEach(b => b.onclick = () => {
  tab = b.dataset.tab;
  document.querySelectorAll('.tabs button').forEach(x => x.classList.toggle('active', x === b));
  syncControls(); render();
});

try { readURL(); } catch(_) {}
window.addEventListener('popstate', () => { try{readURL();}catch(_){} document.querySelectorAll('.tabs button').forEach(b => b.classList.toggle('active', b.dataset.tab === tab)); syncControls(); render(); });
syncControls(); loadStats(); detectPublicMode(); render();
</script>
</body>
</html>