← back to Ventura Corridor
public/coverage.html
382 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Coverage · The Corridor</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;1,400;1,500&family=Inter:wght@200;300;400;500&family=JetBrains+Mono:wght@200;300&display=swap');
:root{--noir:#0a0a0c;--noir-rise:#131316;--ink:#f0ece2;--ink-mute:#888475;--metal:#b89968;--metal-glow:#d4b683;--green:#6a9b73;--red:#b66565;--rule:#2a2622}
*{box-sizing:border-box}body{margin:0;background:var(--noir);color:var(--ink);font-family:Inter,sans-serif;font-weight:300}
header{padding:30px 32px 16px;border-bottom:1px solid var(--rule)}
header h1{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:400;font-size:32px;margin:0}
header h1 em{color:var(--metal)}
header .sub{font-size:11px;letter-spacing:.26em;text-transform:uppercase;color:var(--ink-mute);margin-top:4px}
nav{padding:10px 32px;border-bottom:1px solid var(--rule);display:flex;gap:12px;flex-wrap:wrap;font-size:9px;letter-spacing:.26em;text-transform:uppercase}
nav a{color:var(--ink-mute);text-decoration:none;padding:5px 10px;border:1px solid var(--rule)}
nav a:hover, nav a.active{color:var(--metal);border-color:var(--metal)}
main{padding:32px;max-width:1200px;margin:0 auto}
.hero{display:grid;grid-template-columns:repeat(4,1fr);gap:18px;margin-bottom:32px}
.stat{background:var(--noir-rise);border:1px solid var(--rule);padding:24px}
.stat .v{font-family:'Cormorant Garamond',serif;font-style:italic;font-size:42px;line-height:1;color:var(--metal-glow);font-weight:500;letter-spacing:-0.02em}
.stat .l{font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--ink-mute);margin-top:8px}
.stat.pct .v{color:var(--metal)}
.stat.pct .v::after{content:'%';font-size:18px;color:var(--metal-glow);margin-left:4px}
.bar-section{background:var(--noir-rise);border:1px solid var(--rule);padding:24px;margin-bottom:24px}
.bar-section h3{font-family:'Cormorant Garamond',serif;font-style:italic;color:var(--metal);font-size:24px;margin:0 0 18px;font-weight:500}
.bar-row{display:grid;grid-template-columns:200px 1fr 90px;gap:14px;align-items:center;padding:5px 0;border-bottom:1px solid var(--rule);font-size:13px}
.bar-row .name{text-transform:capitalize;color:var(--ink)}
.bar-row .track{position:relative;height:12px;background:rgba(255,255,255,0.04);border:1px solid var(--rule)}
.bar-row .track .filled{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,var(--metal),var(--metal-glow));min-width:1px}
.bar-row .v{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--ink-mute);text-align:right}
.bar-row .v b{color:var(--metal-glow)}
.spark{display:flex;gap:2px;align-items:flex-end;height:80px;padding:0 4px;margin-top:14px;border-bottom:1px dotted var(--rule);position:relative}
.spark .day{flex:1;background:linear-gradient(180deg,var(--metal-glow),var(--metal));min-width:6px;border-radius:1px;position:relative;cursor:default}
.spark .day:hover{filter:brightness(1.3)}
.spark .day .label{position:absolute;bottom:-18px;left:0;right:0;text-align:center;font-size:8px;color:var(--ink-mute);font-family:'JetBrains Mono',monospace}
.spark-meta{margin-top:30px;font-size:11px;color:var(--ink-mute);font-family:'JetBrains Mono',monospace;display:flex;justify-content:space-between}
</style>
</head>
<body>
<header>
<h1>Coverage <em>report</em></h1>
<div class="sub">how much of the corridor we've drafted · what's left</div>
</header>
<nav>
<a href="/today.html">today</a>
<a href="/magazine.html">magazine</a>
<a href="/issue">issue</a>
<a href="/scoreboard.html">scoreboard</a>
<a href="/feedback.html">feedback</a>
<a href="/coverage.html" class="active">coverage</a>
</nav>
<main>
<div class="hero">
<div class="stat"><div class="v" id="t-total">—</div><div class="l">businesses on the corridor</div></div>
<div class="stat"><div class="v" id="t-cov">—</div><div class="l">have a feature</div></div>
<div class="stat pct"><div class="v" id="t-pct">—</div><div class="l">coverage</div></div>
<div class="stat"><div class="v" id="t-pub">—</div><div class="l">published features</div></div>
</div>
<div class="bar-section">
<h3>Last 14 days · gen rate</h3>
<div class="spark" id="spark"></div>
<div class="spark-meta"><span id="spark-total">—</span><span id="spark-pub">—</span></div>
</div>
<div class="bar-section">
<h3>Coverage over time · daily snapshots</h3>
<svg id="growth" viewBox="0 0 1200 200" preserveAspectRatio="none" style="width:100%;height:160px;background:rgba(255,255,255,0.02);border:1px solid var(--rule)"></svg>
<div class="spark-meta"><span id="growth-meta">—</span><span style="color:var(--ink-mute)">snapshot 11:55 PM daily</span></div>
</div>
<div class="bar-section">
<h3>By vertical · top 30 categories</h3>
<div id="vert"></div>
</div>
<div class="bar-section">
<h3>By city · top 20 corridor cities</h3>
<div id="city"></div>
</div>
<div class="bar-section">
<h3>Idea-loop · accepted ideas (top by score)</h3>
<p style="font-family:'Cormorant Garamond',serif;font-style:italic;font-size:13px;color:var(--ink-mute);margin:0 0 14px">Surviving ideas from the autonomous idea-loop debate panel (qwen3 + gemma3 + deepseek-r1). Full viewer at <a href="http://127.0.0.1:9920/" target="_blank" rel="noopener" style="color:var(--metal)">:9920</a>.</p>
<div id="idea-stats" style="font-family:var(--mono);font-size:10px;letter-spacing:.16em;color:var(--ink-mute);margin-bottom:14px;text-transform:uppercase"></div>
<div id="idea-feed"></div>
<details style="margin-top:18px;padding-top:14px;border-top:1px dotted var(--rule)">
<summary style="cursor:pointer;font-family:var(--mono);font-size:10px;letter-spacing:.18em;color:var(--ink-mute);text-transform:uppercase">show recently rejected ideas (panel said no)</summary>
<div id="idea-rejects" style="margin-top:10px"></div>
</details>
</div>
<div class="bar-section">
<h3>News scrape calendar · last 90 days</h3>
<p style="font-family:'Cormorant Garamond',serif;font-style:italic;font-size:13px;color:var(--ink-mute);margin:0 0 14px">Daily count of articles landed by the news scraper. Quiet stretches mean the nightly 03:15 scrape didn't find new posts; bright days mean fresh content.</p>
<div id="news-cal" style="display:flex;gap:3px;flex-wrap:wrap;align-items:center"></div>
</div>
<div class="bar-section">
<h3>News heatmap · which categories are publishing</h3>
<p style="font-family:'Cormorant Garamond',serif;font-style:italic;font-size:13px;color:var(--ink-mute);margin:0 0 14px">Articles scraped from each business's own website, bucketed by OSM category. Top buckets currently producing the most blog/news/press content.</p>
<div id="news-heat"></div>
</div>
<div class="bar-section">
<h3>Source domains · what platforms host corridor news</h3>
<p style="font-family:'Cormorant Garamond',serif;font-style:italic;font-size:13px;color:var(--ink-mute);margin:0 0 14px">Hostnames the scraper actually pulled articles from — reveals which platforms (WordPress, Squarespace, custom CMS, etc.) corridor businesses publish on.</p>
<div id="news-sources"></div>
</div>
<div class="bar-section">
<h3>Top publishers · fresh in the last 7 days</h3>
<p style="font-family:'Cormorant Garamond',serif;font-style:italic;font-size:13px;color:var(--ink-mute);margin:0 0 14px">Most active corridor publishers — sorted by article count in the last 7 days, then 30 days, then all-time. The 🔥 marker matches the map's "Hot" recency badge (3+ in 7 days).</p>
<div id="news-board"></div>
</div>
<div id="session-glance" style="margin-top:48px;padding:18px 22px;background:linear-gradient(180deg,var(--noir-rise),#0a0a0c);border:2px solid var(--metal);border-radius:6px;font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--cream);text-align:center;letter-spacing:.06em">
<span style="color:var(--metal);font-size:10px;letter-spacing:.32em;text-transform:uppercase">— tonight on the corridor —</span>
<div id="glance-line" style="margin-top:10px;color:var(--metal-glow);font-size:14px">loading…</div>
</div>
</main>
<script>
function escHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
function fmt(n) { return Number(n).toLocaleString('en-US'); }
async function load() {
const d = await fetch('/api/coverage').then(r => r.json());
document.getElementById('t-total').textContent = fmt(d.totals.total_biz);
document.getElementById('t-cov').textContent = fmt(d.totals.biz_with_features);
document.getElementById('t-pct').textContent = d.coverage_pct.toFixed(2);
document.getElementById('t-pub').textContent = fmt(d.totals.published);
// spark
const days = d.recent_14d || [];
const max = Math.max(1, ...days.map(x => Number(x.gen)));
const sparkEl = document.getElementById('spark');
sparkEl.innerHTML = days.map(x => {
const h = (Number(x.gen) / max * 100).toFixed(1);
const label = x.day.slice(5).replace('-', '/');
return `<div class="day" style="height:${h}%" title="${x.day}: ${x.gen} gen, ${x.published} pub"><span class="label">${label}</span></div>`;
}).join('');
const totalGen = days.reduce((a, x) => a + Number(x.gen), 0);
const totalPub = days.reduce((a, x) => a + Number(x.published), 0);
document.getElementById('spark-total').textContent = `${totalGen} generated`;
document.getElementById('spark-pub').textContent = `${totalPub} published`;
// bars
const renderBars = (arr, el) => {
const max = Math.max(1, ...arr.map(r => Number(r.total)));
el.innerHTML = arr.map(r => {
const total = Number(r.total);
const covered = Number(r.covered);
const pct = total ? (covered / total * 100).toFixed(1) : 0;
const fillW = (total / max * 100).toFixed(1);
const coverW = (covered / total * 100).toFixed(1);
return `<div class="bar-row">
<span class="name">${escHtml(r.bucket || r.city || 'misc')}</span>
<div class="track" style="width:${fillW}%;border-color:var(--rule)">
<div class="filled" style="width:${coverW}%"></div>
</div>
<span class="v"><b>${covered}</b>/${total} <span style="color:var(--metal)">${pct}%</span></span>
</div>`;
}).join('');
};
renderBars(d.by_vertical, document.getElementById('vert'));
renderBars(d.by_city, document.getElementById('city'));
// Idea-loop stats — text line + per-type accept-rate mini-bars
try {
const stats = await fetch('/api/ideas/stats').then(r => r.json());
const pct = (stats.accept_rate * 100).toFixed(1);
const acc = stats.accepted_by_type || {};
const rej = stats.rejected_by_type || {};
const types = [...new Set([...Object.keys(acc), ...Object.keys(rej)])].sort();
const bars = types.map(t => {
const a = acc[t] || 0, r = rej[t] || 0, tot = a + r;
const rate = tot ? (a / tot * 100) : 0;
const aw = tot ? (a / tot * 100).toFixed(1) : 0;
return `<div style="display:grid;grid-template-columns:90px 1fr 90px;gap:8px;align-items:center;font-size:10px;letter-spacing:.12em;color:var(--ink-mute);text-transform:uppercase;padding:3px 0">
<span>${t}</span>
<div style="height:10px;background:#2a2622;position:relative">
<div style="position:absolute;left:0;top:0;bottom:0;width:${aw}%;background:linear-gradient(90deg,var(--metal),var(--metal-glow))"></div>
</div>
<span style="font-family:var(--mono);text-align:right">${a}/${tot} · ${rate.toFixed(0)}%</span>
</div>`;
}).join('');
document.getElementById('idea-stats').innerHTML =
`<div style="margin-bottom:8px;font-family:var(--mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--ink-mute)">${stats.accepted} accepted · ${stats.rejected} rejected · ${pct}% accept rate</div>` +
bars;
} catch(e) {}
// Idea-loop feed
try {
const ideas = await fetch('/api/ideas/recent').then(r => r.json());
const elI = document.getElementById('idea-feed');
const rows = ideas.rows || [];
if (!rows.length) {
elI.innerHTML = '<p style="color:var(--ink-mute);font-style:italic;font-family:Cormorant Garamond,serif">No accepted ideas yet — idea-loop will land them as they pass debate panel ≥28/40.</p>';
} else {
elI.innerHTML = rows.map(r => {
const score = r.score_total || 0;
const type = (r.type || 'idea').toUpperCase();
const tColor = { CONNECTION: 'var(--metal-glow)', PRODUCT: '#7ab8a3', AUTOMATION: '#7b9bc4', RENAME: '#c4a07b', KILL: '#c47b7b' }[type] || 'var(--metal)';
return `<div style="padding:10px 0;border-bottom:1px dotted var(--rule);display:grid;grid-template-columns:46px 80px 1fr;gap:14px;align-items:start">
<span style="font-family:var(--mono);font-size:14px;color:var(--metal-glow)">${score}</span>
<span style="font-family:var(--mono);font-size:9px;letter-spacing:.2em;color:${tColor};padding-top:4px">${escHtml(type)}</span>
<div>
<div style="font-family:var(--serif);font-size:15px;font-style:italic">${escHtml(r.title || '(untitled)')}</div>
${r.body ? `<div style="font-size:11px;color:var(--ink-mute);margin-top:4px;line-height:1.5">${escHtml(String(r.body).slice(0,260))}${String(r.body).length>260?'…':''}</div>` : ''}
</div>
</div>`;
}).join('');
}
} catch(e) {
document.getElementById('idea-feed').innerHTML = '<p style="color:#c47b7b">idea-loop bridge unavailable</p>';
}
// Rejects drawer (collapsed by default) — populates only when <details> opened
try {
const rj = await fetch('/api/ideas/rejects').then(r => r.json());
const el = document.getElementById('idea-rejects');
const rows = rj.rows || [];
if (!rows.length) {
el.innerHTML = '<p style="color:var(--ink-mute);font-style:italic;font-family:Cormorant Garamond,serif">No rejects yet.</p>';
} else {
el.innerHTML = rows.map(r => {
const score = r.score_total || 0;
const type = (r.type || 'idea').toUpperCase();
return `<div style="padding:6px 0;border-bottom:1px dotted var(--rule);display:grid;grid-template-columns:46px 80px 1fr;gap:14px;align-items:start;font-size:11px;color:var(--ink-mute)">
<span style="font-family:var(--mono);color:#c47b7b">${score}</span>
<span style="font-family:var(--mono);font-size:9px;letter-spacing:.2em;text-transform:uppercase">${escHtml(type)}</span>
<div>
<div style="font-family:var(--serif);font-style:italic;font-size:13px;color:var(--ink)">${escHtml(r.title || '(untitled)')}</div>
${r.body ? `<div style="font-size:10px;color:var(--ink-mute);margin-top:2px">${escHtml(String(r.body).slice(0,180))}${String(r.body).length>180?'…':''}</div>` : ''}
</div>
</div>`;
}).join('');
}
} catch (e) { /* skip */ }
// Session at a glance — one-liner summary for the night
try {
const s = await fetch('/api/session-stats').then(r => r.json());
const parts = [
`${s.commits} commits`,
`${s.insertions.toLocaleString()} lines`,
`${s.news_total} articles · ${s.news_biz} biz`,
`${s.ideas_accepted}/${s.ideas_accepted + s.ideas_rejected} ideas accepted`
];
document.getElementById('glance-line').innerHTML = parts.join(' · ');
} catch(e) {}
// News scrape calendar — 90-day heatmap above the heatmap section
try {
const cal = await fetch('/api/news/by-day').then(r => r.json());
const days = cal.days || [];
if (days.length) {
const max = Math.max(1, ...days.map(d => Number(d.n)));
document.getElementById('news-cal').innerHTML = days.map(d => {
const n = Number(d.n);
const lvl = n === 0 ? 0 : Math.min(4, Math.ceil((n / max) * 4));
const colors = ['rgba(255,255,255,0.04)', 'rgba(184,153,104,0.22)', 'rgba(184,153,104,0.45)', 'rgba(184,153,104,0.7)', 'rgba(212,182,131,1)'];
return `<div style="width:14px;height:14px;background:${colors[lvl]};border:1px solid var(--rule)" title="${d.day}: ${n} article${n===1?'':'s'}"></div>`;
}).join('');
} else {
document.getElementById('news-cal').innerHTML = '<p style="color:var(--ink-mute);font-style:italic;font-family:Cormorant Garamond,serif">No scrape data yet.</p>';
}
} catch(e) {
document.getElementById('news-cal').innerHTML = '<p style="color:#c47b7b">calendar unavailable</p>';
}
// News heatmap — pulled separately so coverage.html still renders if news data is empty.
try {
const nv = await fetch('/api/news/by-vertical').then(r => r.json());
const rows = nv.rows || [];
if (rows.length === 0) {
document.getElementById('news-heat').innerHTML = '<p style="color:var(--ink-mute);font-style:italic;font-family:Cormorant Garamond,serif">No scraped news yet. The 03:15 cron checks every site nightly.</p>';
} else {
const max = Math.max(1, ...rows.map(r => Number(r.news_count)));
document.getElementById('news-heat').innerHTML = rows.map(r => {
const fillW = (Number(r.news_count) / max * 100).toFixed(1);
return `<div class="bar-row">
<span class="name">${escHtml(r.category)}</span>
<div class="track" style="width:${fillW}%;border-color:var(--rule)">
<div class="filled" style="width:100%;background:linear-gradient(90deg,var(--metal),var(--metal-glow))"></div>
</div>
<span class="v"><b>${r.news_count}</b> · <span style="color:var(--metal)">${r.biz_count} biz</span></span>
</div>`;
}).join('');
}
} catch (e) {
document.getElementById('news-heat').innerHTML = '<p style="color:#c47b7b">news heatmap unavailable</p>';
}
// Leaderboard
try {
const lb = await fetch('/api/news/leaderboard').then(r => r.json());
const lbRows = lb.rows || [];
const board = document.getElementById('news-board');
if (!lbRows.length) {
board.innerHTML = '<p style="color:var(--ink-mute);font-style:italic;font-family:Cormorant Garamond,serif">No news yet.</p>';
} else {
const fmtDate = (s) => s ? new Date(s).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—';
board.innerHTML = lbRows.slice(0, 20).map((r, i) => {
const hot = r.fresh_7d >= 3 ? '🔥 ' : '';
return `<div class="bar-row" style="grid-template-columns:30px 1fr 60px 60px 80px">
<span style="font-family:var(--mono);font-size:9px;color:var(--ink-mute)">#${i + 1}</span>
<a href="/business/${r.id}/news" style="color:var(--ink);text-decoration:none">
<span class="name">${hot}${escHtml(r.name)}</span>
<span class="cities">${escHtml((r.city || '').toLowerCase().replace(/\b\w/g, m => m.toUpperCase()))}${r.category ? ' · ' + escHtml(r.category) : ''}</span>
</a>
<span class="v" style="font-family:var(--mono);font-size:11px;color:var(--metal-glow)">${r.fresh_7d} <span style="color:var(--ink-mute);font-size:9px">/7d</span></span>
<span class="v" style="font-family:var(--mono);font-size:11px;color:var(--metal)">${r.fresh_30d} <span style="color:var(--ink-mute);font-size:9px">/30d</span></span>
<span class="v" style="font-family:var(--mono);font-size:9px;color:var(--ink-mute)">${fmtDate(r.last_at)}</span>
</div>`;
}).join('');
}
} catch (e) {
document.getElementById('news-board').innerHTML = '<p style="color:#c47b7b">leaderboard unavailable</p>';
}
// Source domains
try {
const sd = await fetch('/api/news/sources').then(r => r.json());
const sdRows = sd.rows || [];
const elS = document.getElementById('news-sources');
if (!sdRows.length) {
elS.innerHTML = '<p style="color:var(--ink-mute);font-style:italic;font-family:Cormorant Garamond,serif">No source data yet.</p>';
} else {
const max = Math.max(1, ...sdRows.map(r => Number(r.articles)));
elS.innerHTML = sdRows.map(r => {
const fillW = (Number(r.articles) / max * 100).toFixed(1);
return `<div class="bar-row">
<span class="name"><a href="https://${escHtml(r.host)}" target="_blank" rel="noopener noreferrer" style="color:inherit;text-decoration:none">${escHtml(r.host)}</a></span>
<div class="track" style="width:${fillW}%;border-color:var(--rule)">
<div class="filled" style="width:100%;background:linear-gradient(90deg,#3a5a40,#7a9b73)"></div>
</div>
<span class="v"><b>${r.articles}</b> · <span style="color:var(--metal)">${r.biz_count} biz</span></span>
</div>`;
}).join('');
}
} catch (e) {
document.getElementById('news-sources').innerHTML = '<p style="color:#c47b7b">source breakdown unavailable</p>';
}
// Growth line
const hist = await fetch('/api/coverage/history').then(r => r.json()).catch(() => ({ snapshots: [] }));
const snaps = hist.snapshots || [];
const svg = document.getElementById('growth');
if (!snaps.length) {
svg.innerHTML = `<text x="600" y="100" text-anchor="middle" fill="#888475" font-family="Cormorant Garamond" font-style="italic" font-size="22">first snapshot lands tonight</text>`;
document.getElementById('growth-meta').textContent = `0 snapshots yet`;
} else {
const w = 1200, h = 200, pad = 8;
const xs = snaps.map((s, i) => snaps.length === 1 ? w / 2 : pad + (i / (snaps.length - 1)) * (w - pad * 2));
const maxFeat = Math.max(1, ...snaps.map(s => Number(s.total_features)));
const maxPub = Math.max(1, ...snaps.map(s => Number(s.published)));
const lineFor = (key, max, color, label) => {
const pts = snaps.map((s, i) => `${xs[i].toFixed(1)},${(h - pad - (Number(s[key]) / max) * (h - pad * 2)).toFixed(1)}`).join(' ');
return `<polyline points="${pts}" fill="none" stroke="${color}" stroke-width="1.5"/>` +
snaps.map((s, i) => `<circle cx="${xs[i].toFixed(1)}" cy="${(h - pad - (Number(s[key]) / max) * (h - pad * 2)).toFixed(1)}" r="2.5" fill="${color}"><title>${new Date(s.taken_at).toLocaleString()} · ${label}=${s[key]}</title></circle>`).join('');
};
svg.innerHTML = lineFor('total_features', maxFeat, '#b89968', 'features') +
lineFor('published', maxPub, '#d4b683', 'published');
const first = snaps[0];
const last = snaps[snaps.length - 1];
const days = (new Date(last.taken_at) - new Date(first.taken_at)) / 86400000;
const grew = Number(last.total_features) - Number(first.total_features);
document.getElementById('growth-meta').textContent = `${snaps.length} snapshots · +${grew} features in ${days.toFixed(1)} days`;
}
}
load();
setInterval(load, 5 * 60_000);
</script>
</body>
</html>