[object Object]

← back to Ventura Corridor

iter 155: /coverage.html + /api/coverage — corridor coverage report (0.4% so far), 14-day spark, by-vertical/by-city bars

1feb0f9cbefa58e3173d4e7d077ab07dafab8487 · 2026-05-06 19:26:03 -0700 · SteveStudio2

Files touched

Diff

commit 1feb0f9cbefa58e3173d4e7d077ab07dafab8487
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 6 19:26:03 2026 -0700

    iter 155: /coverage.html + /api/coverage — corridor coverage report (0.4% so far), 14-day spark, by-vertical/by-city bars
---
 public/coverage.html | 131 +++++++++++++++++++++++++++++++++++++++++++++++++++
 public/today.html    |   1 +
 src/server/index.ts  |  64 +++++++++++++++++++++++++
 3 files changed, 196 insertions(+)

diff --git a/public/coverage.html b/public/coverage.html
new file mode 100644
index 0000000..7b3982b
--- /dev/null
+++ b/public/coverage.html
@@ -0,0 +1,131 @@
+<!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>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>
+
+</main>
+<script>
+function escHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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'));
+}
+load();
+setInterval(load, 5 * 60_000);
+</script>
+</body>
+</html>
diff --git a/public/today.html b/public/today.html
index 2e38758..d87275f 100644
--- a/public/today.html
+++ b/public/today.html
@@ -124,6 +124,7 @@
     <a href="/admin/ig-drafts/" target="_blank">📷 ig drafts</a>
     <a href="/standup">🎙 standup</a>
     <a href="/cover-picker.html">⛧ cover</a>
+    <a href="/coverage.html">▰ coverage</a>
     <a href="/crawl-derby.html">🏇 derby</a>
     <a href="/today.html" class="active">today</a>
     <span class="theme-toggle-host" style="display:inline-flex;align-items:center;margin-left:6px"></span>
diff --git a/src/server/index.ts b/src/server/index.ts
index cb50859..5fbb5ee 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -83,6 +83,8 @@ const ADMIN_PATHS = [
   /^\/api\/find\/?$/i,
   /^\/words\/?$/i,
   /^\/quotes\/?$/i,
+  /^\/coverage(\.html)?\/?$/i,
+  /^\/api\/coverage\/?$/i,
   /^\/sitemap\.xml\/?$/i,
   /^\/robots\.txt\/?$/i,
   /^\/issue(\/[\w-]+)?\/feed\.xml\/?$/i,
@@ -1893,6 +1895,68 @@ app.get('/api/magazine/random', async (req, res) => {
   }
 });
 
+// Coverage report — what % of corridor businesses have a feature yet, broken down
+// by vertical and city. Used for /coverage.html admin viewer + standup metrics.
+app.get('/api/coverage', async (_req, res) => {
+  try {
+    const totals = await query(`
+      SELECT
+        (SELECT count(*) FROM businesses) AS total_biz,
+        (SELECT count(DISTINCT business_id) FROM magazine_features) AS biz_with_features,
+        (SELECT count(*) FROM magazine_features) AS total_features,
+        (SELECT count(*) FROM magazine_features WHERE status='draft') AS draft,
+        (SELECT count(*) FROM magazine_features WHERE status='reviewed') AS reviewed,
+        (SELECT count(*) FROM magazine_features WHERE status='published') AS published,
+        (SELECT count(*) FROM magazine_features WHERE status='spiked') AS spiked
+    `);
+    const byVertical = await query(`
+      WITH
+        biz_by_naics AS (
+          SELECT b.id,
+                 lower(coalesce(b.raw->>'primary_naics_description', 'unclassified')) AS naics
+          FROM businesses b
+        ),
+        feat_by_biz AS (
+          SELECT business_id FROM magazine_features
+        )
+      SELECT bn.naics AS bucket,
+             count(*) AS total,
+             count(*) FILTER (WHERE bn.id IN (SELECT business_id FROM feat_by_biz)) AS covered
+      FROM biz_by_naics bn
+      GROUP BY bn.naics
+      HAVING count(*) >= 25
+      ORDER BY total DESC LIMIT 30
+    `);
+    const byCity = await query(`
+      SELECT coalesce(city, 'unknown') AS city,
+             count(*) AS total,
+             count(*) FILTER (WHERE EXISTS (SELECT 1 FROM magazine_features mf WHERE mf.business_id = b.id)) AS covered
+      FROM businesses b
+      GROUP BY coalesce(city, 'unknown')
+      HAVING count(*) >= 25
+      ORDER BY total DESC LIMIT 20
+    `);
+    const recent7d = await query(`
+      SELECT to_char(date_trunc('day', generated_at AT TIME ZONE 'America/Los_Angeles'), 'YYYY-MM-DD') AS day,
+             count(*) AS gen,
+             count(*) FILTER (WHERE status='published') AS published
+      FROM magazine_features
+      WHERE generated_at > NOW() - INTERVAL '14 days'
+      GROUP BY 1 ORDER BY 1
+    `);
+    const t = totals.rows[0];
+    res.json({
+      totals: t,
+      coverage_pct: t.total_biz ? +(Number(t.biz_with_features) / Number(t.total_biz) * 100).toFixed(2) : 0,
+      by_vertical: byVertical.rows,
+      by_city: byCity.rows,
+      recent_14d: recent7d.rows,
+    });
+  } catch (e: any) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
 // Magazine activity feed — unified stream across feature gen/publish/cover + feedback + inquiries.
 // Powers a "what just happened" widget on /today.html and the daily-standup script.
 app.get('/api/magazine/activity', async (req, res) => {

← a80fd42 iter 154: /magazine/:id/ad-pack.html — short/medium/long cop  ·  back to Ventura Corridor  ·  iter 156: /api/magazine/quality-flags audit + nav-pill on to 37c543b →