[object Object]

← back to Ventura Corridor

iter 158: coverage_snapshots table + nightly 11:55 PM job + /api/coverage/history + SVG growth line on /coverage.html

52e164c873827d2b89c82dabfac5c167e78ac7ff · 2026-05-06 19:33:41 -0700 · SteveStudio2

Files touched

Diff

commit 52e164c873827d2b89c82dabfac5c167e78ac7ff
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 6 19:33:41 2026 -0700

    iter 158: coverage_snapshots table + nightly 11:55 PM job + /api/coverage/history + SVG growth line on /coverage.html
---
 package.json                  |  3 ++-
 public/coverage.html          | 32 ++++++++++++++++++++++++++++++++
 src/jobs/coverage_snapshot.ts | 38 ++++++++++++++++++++++++++++++++++++++
 src/server/index.ts           | 14 ++++++++++++++
 4 files changed, 86 insertions(+), 1 deletion(-)

diff --git a/package.json b/package.json
index 43870d0..2ff0279 100644
--- a/package.json
+++ b/package.json
@@ -32,7 +32,8 @@
     "magazine:regen-thin": "tsx src/jobs/regen_thin.ts",
     "magazine:ig-dryrun": "tsx src/jobs/ig_autopost_dryrun.ts",
     "magazine:standup": "tsx src/jobs/daily_standup_audio.ts",
-    "magazine:auto-cover": "tsx src/jobs/auto_cover.ts"
+    "magazine:auto-cover": "tsx src/jobs/auto_cover.ts",
+    "magazine:coverage-snapshot": "tsx src/jobs/coverage_snapshot.ts"
   },
   "dependencies": {
     "@types/multer": "^2.1.0",
diff --git a/public/coverage.html b/public/coverage.html
index 7b3982b..e84c531 100644
--- a/public/coverage.html
+++ b/public/coverage.html
@@ -68,6 +68,12 @@ main{padding:32px;max-width:1200px;margin:0 auto}
   <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>
@@ -123,6 +129,32 @@ async function load() {
   };
   renderBars(d.by_vertical, document.getElementById('vert'));
   renderBars(d.by_city, document.getElementById('city'));
+
+  // 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);
diff --git a/src/jobs/coverage_snapshot.ts b/src/jobs/coverage_snapshot.ts
new file mode 100644
index 0000000..bfc5913
--- /dev/null
+++ b/src/jobs/coverage_snapshot.ts
@@ -0,0 +1,38 @@
+// Captures a single coverage snapshot per run. Daily launchd at 11:55 PM
+// gives us a sharp end-of-day count; over weeks/months we get trend lines.
+//
+// Run manually:  npm run magazine:coverage-snapshot
+
+import { Pool } from 'pg';
+
+const pool = new Pool({ host: '/tmp', database: 'ventura_corridor' });
+
+async function main() {
+  const r = await pool.query(`
+    INSERT INTO coverage_snapshots (
+      total_biz, biz_with_features, total_features,
+      drafts, reviewed, published, spiked,
+      feedback_total, feedback_unread,
+      inquiries_total, inquiries_open
+    )
+    SELECT
+      (SELECT count(*) FROM businesses),
+      (SELECT count(DISTINCT business_id) FROM magazine_features),
+      (SELECT count(*) FROM magazine_features),
+      (SELECT count(*) FROM magazine_features WHERE status='draft'),
+      (SELECT count(*) FROM magazine_features WHERE status='reviewed'),
+      (SELECT count(*) FROM magazine_features WHERE status='published'),
+      (SELECT count(*) FROM magazine_features WHERE status='spiked'),
+      (SELECT count(*) FROM reader_feedback),
+      (SELECT count(*) FROM reader_feedback WHERE NOT reviewed),
+      (SELECT count(*) FROM sponsor_inquiries),
+      (SELECT count(*) FROM sponsor_inquiries WHERE status='new')
+    RETURNING id, taken_at, total_biz, biz_with_features, total_features, published
+  `);
+  const row = r.rows[0];
+  console.log(`[coverage-snapshot] #${row.id} @ ${new Date(row.taken_at).toISOString()}`);
+  console.log(`  ${row.total_biz} biz · ${row.biz_with_features} covered · ${row.total_features} features (${row.published} published)`);
+  await pool.end();
+}
+
+main().catch(e => { console.error('[coverage-snapshot]', e); process.exit(1); });
diff --git a/src/server/index.ts b/src/server/index.ts
index 279494f..a562eab 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -2037,6 +2037,20 @@ app.get('/api/coverage', async (_req, res) => {
   }
 });
 
+// Coverage history — feeds the "growth over time" line on /coverage.html.
+app.get('/api/coverage/history', async (_req, res) => {
+  try {
+    const r = await query(`
+      SELECT id, taken_at, total_biz, biz_with_features, total_features,
+             drafts, reviewed, published, feedback_total, inquiries_total
+      FROM coverage_snapshots ORDER BY taken_at ASC LIMIT 365
+    `);
+    res.json({ count: r.rowCount, snapshots: r.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) => {

← 8fee706 iter 157: /api/magazine/feature-of-the-month — manual cover  ·  back to Ventura Corridor  ·  iter 159: /api/magazine/gen-queue + 'Up next' rail on today. 4b6a73d →