← back to Ventura Corridor
iter 83+84: magazine watchdog launchd + scoreboard wall — src/jobs/magazine_watchdog.ts checks every 15min via com.steve.ventura-corridor-magazine-watchdog launchd whether (a) any generate_features process is alive AND (b) magazine_features count grew in last 10min; if stalled, kills the hung pid and re-spawns nohup gen 200 fresh; logs to logs/magazine-watchdog.log; npm run magazine:watchdog wired. /api/magazine/scoreboard returns 5 leaderboards (top_viewed by views, hot_verticals by feature count, latest 10 by generated_at, longest_editorial by character count, sponsor_candidates with ads_count > 0); /scoreboard.html newsroom-themed grid of 5 boards each with rank-numbered ol list, auto-refresh 60s; nav rail links today / magazine / issue / scoreboard / buildings
e460f0695d18e1e7fe26da6518e8e077e950a9fb · 2026-05-06 17:00:42 -0700 · SteveStudio2
Files touched
M package.jsonA public/scoreboard.htmlA src/jobs/magazine_watchdog.tsM src/server/index.ts
Diff
commit e460f0695d18e1e7fe26da6518e8e077e950a9fb
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date: Wed May 6 17:00:42 2026 -0700
iter 83+84: magazine watchdog launchd + scoreboard wall — src/jobs/magazine_watchdog.ts checks every 15min via com.steve.ventura-corridor-magazine-watchdog launchd whether (a) any generate_features process is alive AND (b) magazine_features count grew in last 10min; if stalled, kills the hung pid and re-spawns nohup gen 200 fresh; logs to logs/magazine-watchdog.log; npm run magazine:watchdog wired. /api/magazine/scoreboard returns 5 leaderboards (top_viewed by views, hot_verticals by feature count, latest 10 by generated_at, longest_editorial by character count, sponsor_candidates with ads_count > 0); /scoreboard.html newsroom-themed grid of 5 boards each with rank-numbered ol list, auto-refresh 60s; nav rail links today / magazine / issue / scoreboard / buildings
---
package.json | 3 +-
public/scoreboard.html | 87 +++++++++++++++++++++++++++++++++++++++++++
src/jobs/magazine_watchdog.ts | 76 +++++++++++++++++++++++++++++++++++++
src/server/index.ts | 22 +++++++++++
4 files changed, 187 insertions(+), 1 deletion(-)
diff --git a/package.json b/package.json
index dbe5afb..38e3cd8 100644
--- a/package.json
+++ b/package.json
@@ -24,7 +24,8 @@
"snapshot": "tsx src/jobs/daily_snapshot.ts",
"digest": "tsx src/jobs/weekly_digest.ts",
"magazine:gen": "tsx src/jobs/generate_features.ts",
- "magazine:photos": "tsx src/jobs/find_photos.ts"
+ "magazine:photos": "tsx src/jobs/find_photos.ts",
+ "magazine:watchdog": "tsx src/jobs/magazine_watchdog.ts"
},
"dependencies": {
"dotenv": "^16.4.5",
diff --git a/public/scoreboard.html b/public/scoreboard.html
new file mode 100644
index 0000000..46599ee
--- /dev/null
+++ b/public/scoreboard.html
@@ -0,0 +1,87 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Scoreboard · 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;0,500;1,400;1,500&family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@200;300;400&display=swap');
+:root{--noir:#0a0a0c;--noir-rise:#131316;--ink:#f0ece2;--ink-mute:#888475;--metal:#b89968;--metal-glow:#d4b683;--green:#6a9b73;--rule:#2a2622}
+*{box-sizing:border-box}
+html,body{margin:0;background:var(--noir);color:var(--ink);font-family:'Inter',sans-serif;font-weight:300;min-height:100vh}
+header{padding:48px 24px 24px;text-align:center;border-bottom:1px solid var(--rule)}
+header .kicker{font-size:10px;letter-spacing:.4em;text-transform:uppercase;color:var(--metal);font-weight:500}
+header h1{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:54px;margin:8px 0 0;color:var(--ink)}
+header h1 em{color:var(--metal-glow);font-style:normal}
+nav{padding:12px 24px;text-align:center;border-bottom:1px solid var(--rule);font-size:9px;letter-spacing:.22em;text-transform:uppercase}
+nav a{color:var(--ink-mute);margin:0 10px;text-decoration:none;padding:4px 0}
+nav a:hover{color:var(--metal)}
+
+main{display:grid;grid-template-columns:repeat(auto-fit,minmax(360px,1fr));gap:24px;padding:32px;max-width:1400px;margin:0 auto}
+.board{background:var(--noir-rise);border:1px solid var(--rule);padding:20px 22px}
+.board h2{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:22px;color:var(--metal);margin:0 0 4px}
+.board .deck{font-size:9px;letter-spacing:.26em;color:var(--ink-mute);text-transform:uppercase;margin-bottom:14px}
+.board ol{list-style:none;padding:0;margin:0;counter-reset:rank}
+.board li{counter-increment:rank;display:grid;grid-template-columns:auto 1fr auto;gap:12px;padding:8px 0;border-bottom:1px dotted var(--rule);align-items:center}
+.board li:last-child{border-bottom:none}
+.board li::before{content:counter(rank,decimal-leading-zero);font-family:'Cormorant Garamond',serif;font-style:italic;font-size:18px;color:var(--metal);min-width:30px}
+.board li a{color:var(--ink);text-decoration:none}
+.board li a:hover{color:var(--metal-glow)}
+.board li .name{font-family:'Cormorant Garamond',serif;font-size:15px;color:var(--ink-mute);font-style:italic}
+.board li .v{font-family:'JetBrains Mono',monospace;font-size:13px;color:var(--metal-glow);text-align:right}
+.empty{padding:24px;text-align:center;color:var(--ink-mute);font-style:italic}
+</style>
+</head>
+<body>
+<header>
+ <div class="kicker">The Corridor · Newsroom</div>
+ <h1>Score<em>board</em></h1>
+</header>
+<nav>
+ <a href="/today.html">today</a>
+ <a href="/magazine.html">magazine</a>
+ <a href="/issue">public issue</a>
+ <a href="/scoreboard.html" style="color:var(--metal);border-bottom:1px solid var(--metal)">scoreboard</a>
+ <a href="/buildings.html">buildings</a>
+</nav>
+<main id="boards"><div class="empty">Loading…</div></main>
+
+<script>
+function escHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
+async function load() {
+ const d = await fetch('/api/magazine/scoreboard').then(r => r.json());
+ const boards = [
+ {
+ title: 'Most viewed', deck: 'reader engagement', rows: d.top_viewed,
+ render: r => `<a href="/magazine/${r.id}">${escHtml(r.headline || r.biz_name)}</a> <span class="name">${escHtml(r.biz_name)}</span><span class="v">${r.views} 👁</span>`
+ },
+ {
+ title: 'Hottest verticals', deck: 'feature counts by category', rows: d.hot_verticals,
+ render: r => `<span style="text-transform:capitalize">${escHtml((r.category_tag || 'uncategorized').replace(/-/g, ' '))}</span><span></span><span class="v">${r.n}</span>`
+ },
+ {
+ title: 'Sponsor candidates', deck: 'paid-ad runners worth pitching', rows: d.sponsor_candidates,
+ render: r => `<a href="/magazine/${r.id}">${escHtml(r.headline || r.biz_name)}</a><span class="name">${escHtml(r.biz_name)}</span><span class="v">$ ${r.ads}× ads</span>`
+ },
+ {
+ title: 'Latest features', deck: 'fresh off the press', rows: d.latest,
+ render: r => `<a href="/magazine/${r.id}">${escHtml(r.headline || r.biz_name)}</a><span class="name">${escHtml((r.category_tag || '').replace(/-/g,' '))}</span><span class="v">${new Date(r.generated_at).toLocaleDateString('en-US',{month:'short',day:'numeric'})}</span>`
+ },
+ {
+ title: 'Longest editorial', deck: 'qwen3 ran with the prompt', rows: d.longest_editorial,
+ render: r => `<a href="/magazine/${r.id}">${escHtml(r.headline || r.biz_name)}</a><span class="name">${escHtml(r.biz_name)}</span><span class="v">${r.len}c</span>`
+ },
+ ];
+ document.getElementById('boards').innerHTML = boards.map(b => `
+ <section class="board">
+ <h2>${escHtml(b.title)}</h2>
+ <div class="deck">${escHtml(b.deck)}</div>
+ ${(b.rows && b.rows.length) ? `<ol>${b.rows.map(r => `<li>${b.render(r)}</li>`).join('')}</ol>` : `<div class="empty">No data yet.</div>`}
+ </section>
+ `).join('');
+}
+load();
+setInterval(load, 60_000);
+</script>
+</body>
+</html>
diff --git a/src/jobs/magazine_watchdog.ts b/src/jobs/magazine_watchdog.ts
new file mode 100644
index 0000000..bfdfbdd
--- /dev/null
+++ b/src/jobs/magazine_watchdog.ts
@@ -0,0 +1,76 @@
+/**
+ * Watchdog for the magazine generator. Runs every N min, checks whether:
+ * 1. Any generate_features.ts process is alive
+ * 2. magazine_features count grew in the last 10 min
+ * If both fail, kicks off a fresh `npm run magazine:gen 200` in nohup.
+ *
+ * $ npx tsx src/jobs/magazine_watchdog.ts
+ */
+import 'dotenv/config';
+import { spawn, execSync } from 'node:child_process';
+import { pool, query } from '../../db/pool.ts';
+import { existsSync, mkdirSync, appendFileSync } from 'node:fs';
+import { homedir } from 'node:os';
+import path from 'node:path';
+
+const PROJECT = path.join(homedir(), 'Projects', 'ventura-corridor');
+const LOG_DIR = path.join(PROJECT, 'logs');
+const LOG_FILE = path.join(LOG_DIR, 'magazine-watchdog.log');
+const STALL_MIN = 10;
+
+function log(msg: string) {
+ if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true });
+ const line = `[${new Date().toISOString()}] ${msg}\n`;
+ appendFileSync(LOG_FILE, line);
+ process.stdout.write(line);
+}
+
+async function main() {
+ // 1. Is there a live generator process?
+ let alive = false;
+ try {
+ const out = execSync(`pgrep -af 'generate_features\\.ts' || true`, { encoding: 'utf8' });
+ alive = out.trim().length > 0;
+ } catch {}
+
+ // 2. Has count grown in the last STALL_MIN min?
+ const r = await query(`
+ SELECT
+ count(*) AS total,
+ count(*) FILTER (WHERE generated_at > NOW() - INTERVAL '${STALL_MIN} minutes') AS recent
+ FROM magazine_features
+ `);
+ const total = Number(r.rows[0].total);
+ const recent = Number(r.rows[0].recent);
+
+ log(`alive=${alive} total=${total} recent_${STALL_MIN}min=${recent}`);
+
+ if (alive && recent > 0) {
+ log(`OK · gen healthy`);
+ await pool.end();
+ return;
+ }
+ if (alive && recent === 0) {
+ log(`STALL · gen process alive but no rows in ${STALL_MIN} min — killing + restarting`);
+ try { execSync(`pkill -9 -f 'generate_features\\.ts' || true`); } catch {}
+ await new Promise(r => setTimeout(r, 2000));
+ } else if (!alive) {
+ log(`DOWN · no gen process — starting`);
+ }
+
+ // Spawn fresh gen, detached
+ const child = spawn('bash', ['-lc',
+ `cd "${PROJECT}" && nohup npx tsx src/jobs/generate_features.ts 200 >> logs/magazine-gen-overnight.log 2>&1 &`
+ ], { detached: true, stdio: 'ignore' });
+ child.unref();
+ await new Promise(r => setTimeout(r, 3000));
+ let newPid = '';
+ try { newPid = execSync(`pgrep -f 'generate_features\\.ts 200' | head -1`, { encoding: 'utf8' }).trim(); } catch {}
+ log(`RESTART · started new gen pid=${newPid}`);
+ await pool.end();
+}
+
+main().catch(e => {
+ log(`fatal: ${e.message}`);
+ process.exit(1);
+});
diff --git a/src/server/index.ts b/src/server/index.ts
index 074a024..4c6e703 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -74,6 +74,7 @@ const ADMIN_PATHS = [
/^\/api\/magazine(\/.*)?$/i,
/^\/magazine\/\d+\/?$/i,
/^\/issue(\/[a-z-]+)?\/?$/i,
+ /^\/scoreboard(\.html)?\/?$/i,
/^\/crawl-derby(\.html)?\/?$/i,
/^\/api\/crawl(\/.*)?$/i,
/^\/postcards(\.html)?\/?$/i,
@@ -1744,6 +1745,27 @@ app.get('/api/data-freshness', async (_req, res) => {
}
});
+app.get('/api/magazine/scoreboard', async (_req, res) => {
+ try {
+ const [topViewed, hotVerticals, latest, longest, sponsors] = await Promise.all([
+ query(`SELECT mf.id, mf.headline, mf.views, b.name AS biz_name FROM magazine_features mf JOIN businesses b ON b.id=mf.business_id WHERE mf.views > 0 ORDER BY mf.views DESC LIMIT 10`),
+ query(`SELECT category_tag, count(*) AS n FROM magazine_features GROUP BY category_tag ORDER BY n DESC LIMIT 10`),
+ query(`SELECT mf.id, mf.headline, mf.generated_at, b.name AS biz_name, mf.category_tag FROM magazine_features mf JOIN businesses b ON b.id=mf.business_id ORDER BY mf.generated_at DESC LIMIT 10`),
+ query(`SELECT mf.id, mf.headline, length(mf.editorial) AS len, b.name AS biz_name FROM magazine_features mf JOIN businesses b ON b.id=mf.business_id WHERE mf.editorial IS NOT NULL ORDER BY length(mf.editorial) DESC LIMIT 10`),
+ query(`SELECT mf.id, mf.headline, b.name AS biz_name, COALESCE((be.ad_signals->>'paid_ads_count')::int,0) AS ads FROM magazine_features mf JOIN businesses b ON b.id=mf.business_id LEFT JOIN business_enrichment be ON be.business_id=mf.business_id WHERE COALESCE((be.ad_signals->>'paid_ads_count')::int,0) > 0 ORDER BY ads DESC LIMIT 10`)
+ ]);
+ res.json({
+ top_viewed: topViewed.rows,
+ hot_verticals: hotVerticals.rows,
+ latest: latest.rows,
+ longest_editorial: longest.rows,
+ sponsor_candidates: sponsors.rows
+ });
+ } catch (e: any) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
app.get('/api/magazine/stats', async (_req, res) => {
try {
const r = await query(`
← 076d509 iter 82: per-category mood gradients on /magazine.html photo
·
back to Ventura Corridor
·
iter 85: feature-of-the-day on /today.html — /api/magazine/f 3f4099c →