← 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
M package.jsonM public/coverage.htmlA src/jobs/coverage_snapshot.tsM src/server/index.ts
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 →