← 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 & 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 & 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 & 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,'<');
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,'<')}</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,'<')} <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,'<')} <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>