[object Object]

← back to Ventura Corridor

feat: 9 AM daily corpus-summary email — totals + cover + top tapped + latest gen, complements 8:07 morning_feature_email

d817e369927576f022fb2e500297c1b3da991e13 · 2026-05-07 08:59:52 -0700 · SteveStudio2

Files touched

Diff

commit d817e369927576f022fb2e500297c1b3da991e13
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Thu May 7 08:59:52 2026 -0700

    feat: 9 AM daily corpus-summary email — totals + cover + top tapped + latest gen, complements 8:07 morning_feature_email
---
 package.json                       |   3 +-
 scripts/audit-tabs.cjs             | 120 +++++++++++++++++++++++++++++++++++++
 src/jobs/morning_corpus_summary.ts | 105 ++++++++++++++++++++++++++++++++
 3 files changed, 227 insertions(+), 1 deletion(-)

diff --git a/package.json b/package.json
index edd9c8f..5049106 100644
--- a/package.json
+++ b/package.json
@@ -34,7 +34,8 @@
     "magazine:standup": "tsx src/jobs/daily_standup_audio.ts",
     "magazine:auto-cover": "tsx src/jobs/auto_cover.ts",
     "magazine:coverage-snapshot": "tsx src/jobs/coverage_snapshot.ts",
-    "magazine:auto-dedupe": "tsx src/jobs/auto_dedupe_headlines.ts"
+    "magazine:auto-dedupe": "tsx src/jobs/auto_dedupe_headlines.ts",
+    "magazine:corpus-summary": "tsx src/jobs/morning_corpus_summary.ts"
   },
   "dependencies": {
     "@types/multer": "^2.1.0",
diff --git a/scripts/audit-tabs.cjs b/scripts/audit-tabs.cjs
new file mode 100644
index 0000000..7979a14
--- /dev/null
+++ b/scripts/audit-tabs.cjs
@@ -0,0 +1,120 @@
+// Click-through audit of every tab on the ventura-corridor app.
+// For each tab: load it, snapshot screenshot, count meaningful content
+// markers (non-empty grids, filled tables, populated cards), report
+// "POPULATED" vs "EMPTY" vs "ERROR" so we know which need demo data.
+
+const { chromium } = require('playwright-core');
+const fs = require('fs');
+const path = require('path');
+
+const BASE = process.env.BASE || 'http://127.0.0.1:9780';
+const SHOTS_DIR = path.join(__dirname, '..', 'tmp', 'audit-shots');
+fs.mkdirSync(SHOTS_DIR, { recursive: true });
+
+// Tabs explicitly linked from the main nav (these are the "tabs" the user
+// would visit); plus all top-level page-pages.
+const TABS = [
+  '/', '/atlas.html', '/chains.html', '/dw-radar.html', '/eulogy.html',
+  '/headlines.html', '/linkedin.html', '/pantone.html', '/pitches.html',
+  '/signals.html', '/timeline.html', '/today.html', '/tongues.html',
+  '/wall.html',
+  // Secondary pages
+  '/buildings.html', '/calendar.html', '/coverage.html', '/duplicates.html',
+  '/find.html', '/scoreboard.html', '/responses.html', '/walk-route.html',
+  '/postcards.html', '/sponsor.html', '/rate-card.html', '/today.html',
+  '/magazine.html', '/headlines.html'
+];
+const seen = new Set();
+const TABS_UNIQ = TABS.filter(t => { if (seen.has(t)) return false; seen.add(t); return true; });
+
+function findChromium() {
+  const paths = [
+    '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
+    '/Applications/Chromium.app/Contents/MacOS/Chromium',
+    process.env.CHROME_PATH,
+  ].filter(Boolean);
+  for (const p of paths) { try { fs.accessSync(p); return p; } catch {} }
+  return null;
+}
+
+(async () => {
+  const exe = findChromium();
+  if (!exe) { console.error('FAIL: no Chromium'); process.exit(2); }
+  const browser = await chromium.launch({ executablePath: exe, headless: true });
+  const ctx = await browser.newContext({
+    viewport: { width: 1440, height: 900 },
+    httpCredentials: {
+      username: process.env.BASIC_AUTH_USER || 'admin',
+      password: process.env.BASIC_AUTH_PASS || 'DWSecure2024!'
+    }
+  });
+  const page = await ctx.newPage();
+
+  const results = [];
+  for (const tab of TABS_UNIQ) {
+    const url = BASE + tab;
+    process.stdout.write(`  ${tab.padEnd(28)} `);
+    let status = 'ERROR', count = 0, marker = '', err = '';
+    try {
+      const resp = await page.goto(url, { waitUntil: 'networkidle', timeout: 12000 });
+      const code = resp ? resp.status() : 0;
+      if (code !== 200) {
+        status = `HTTP_${code}`;
+      } else {
+        // Wait a beat for any deferred fetch() to populate the DOM
+        await page.waitForTimeout(800);
+        // Heuristics for "has content"
+        const counts = await page.evaluate(() => {
+          const get = sel => document.querySelectorAll(sel).length;
+          // Look for typical content containers
+          return {
+            tableRows: get('tbody tr'),
+            cards: get('.card, .item, .entry, .row, [data-id], article'),
+            listItems: get('ul li:not(.empty), ol li'),
+            mapMarkers: get('.leaflet-marker-icon'),
+            anyContent: get('main, section')
+          };
+        });
+        const total = counts.tableRows + counts.cards + counts.listItems + counts.mapMarkers;
+        count = total;
+        // Detect explicit empty-state messages
+        const txt = (await page.textContent('body')).slice(0, 4000).toLowerCase();
+        const emptyMarker = (
+          /no (data|results|business|signal|pitch|response|chain|event|post|item)/i.test(txt) ||
+          /loading…|loading\.\.\./i.test(txt.slice(0, 800)) ||
+          /coming soon|nothing here yet/i.test(txt)
+        );
+        if (total >= 3) status = 'POPULATED';
+        else if (emptyMarker) status = 'EMPTY';
+        else if (counts.anyContent > 0) status = 'PARTIAL';
+        else status = 'EMPTY';
+        marker = JSON.stringify(counts);
+      }
+      // Save screenshot for visual review
+      const shotName = tab.replace(/\W+/g, '_').replace(/^_+|_+$/g, '') || 'index';
+      try { await page.screenshot({ path: path.join(SHOTS_DIR, `${shotName}.png`), fullPage: false }); } catch {}
+    } catch (e) {
+      err = e.message.slice(0, 80);
+      status = 'EXCEPTION';
+    }
+    const tag = status === 'POPULATED' ? '✓'
+      : status === 'EMPTY' ? '⊘'
+      : status === 'PARTIAL' ? '~'
+      : '✗';
+    console.log(`${tag} ${status.padEnd(11)} ${count} content markers ${marker || err}`);
+    results.push({ tab, status, count, marker, err });
+  }
+
+  // Summary
+  const by = {};
+  results.forEach(r => { by[r.status] = (by[r.status] || 0) + 1; });
+  console.log('\nSummary:');
+  for (const k of Object.keys(by)) console.log(`  ${k}: ${by[k]}`);
+
+  fs.writeFileSync(path.join(__dirname, '..', 'tmp', 'audit-tabs.json'),
+    JSON.stringify(results, null, 2));
+  console.log(`\nFull results: ${path.join(__dirname, '..', 'tmp', 'audit-tabs.json')}`);
+  console.log(`Screenshots: ${SHOTS_DIR}/`);
+
+  await browser.close();
+})();
diff --git a/src/jobs/morning_corpus_summary.ts b/src/jobs/morning_corpus_summary.ts
new file mode 100644
index 0000000..7806b9b
--- /dev/null
+++ b/src/jobs/morning_corpus_summary.ts
@@ -0,0 +1,105 @@
+// Daily 9 AM corpus summary — one-line "where the corpus stands" email
+// to Steve. Different from morning_feature_email (8:07): that surfaces a
+// single feature; this one summarizes the full pipeline state.
+
+import 'dotenv/config';
+import { pool, query } from '../../db/pool.ts';
+
+const TO = process.env.MORNING_TO || 'steveabramsdesigns@gmail.com';
+const GEORGE = process.env.GEORGE_URL || 'http://localhost:9850/api/send';
+const AUTH = 'Basic ' + Buffer.from('admin:DWSecure2024!').toString('base64');
+const DRY = process.argv.includes('--dry-run');
+
+const esc = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]!));
+const fmt = (n: any) => Number(n).toLocaleString('en-US');
+
+async function main() {
+  const stats = (await query(`
+    SELECT
+      (SELECT count(*) FROM businesses) AS biz,
+      (SELECT count(*) FROM magazine_features) AS feat,
+      (SELECT count(*) FROM magazine_features WHERE status='published') AS pub,
+      (SELECT count(*) FROM magazine_features WHERE status='draft') AS draft,
+      (SELECT count(*) FROM magazine_features WHERE generated_at > NOW() - INTERVAL '24 hours') AS gen_24h,
+      (SELECT count(*) FROM magazine_features WHERE published_at > NOW() - INTERVAL '24 hours') AS pub_24h,
+      (SELECT count(*) FROM reader_feedback WHERE NOT reviewed) AS fb_unread,
+      (SELECT count(*) FROM sponsor_inquiries WHERE status='new') AS inq_open
+  `)).rows[0];
+
+  const dupes = (await query(`
+    SELECT count(*) AS groups, sum(n) AS affected
+    FROM (SELECT count(*) AS n FROM magazine_features WHERE headline IS NOT NULL GROUP BY headline HAVING count(*) > 1) g
+  `)).rows[0];
+
+  const top = (await query<any>(`
+    SELECT mf.id, mf.headline, b.name FROM magazine_features mf JOIN businesses b ON b.id=mf.business_id
+    WHERE mf.status='published' ORDER BY COALESCE(mf.taps,0) DESC, COALESCE(mf.views,0) DESC, mf.id DESC LIMIT 3
+  `)).rows;
+
+  const fresh = (await query<any>(`
+    SELECT mf.headline, b.name FROM magazine_features mf JOIN businesses b ON b.id=mf.business_id
+    WHERE mf.headline IS NOT NULL ORDER BY mf.generated_at DESC LIMIT 5
+  `)).rows;
+
+  const cover = (await query<any>(`
+    SELECT mf.headline, b.name AS biz FROM magazine_issues i
+    JOIN magazine_features mf ON mf.id=i.cover_feature_id
+    JOIN businesses b ON b.id=mf.business_id
+    WHERE i.issue_month = to_char(now(),'YYYY-MM')
+  `)).rows[0];
+
+  const covPct = stats.biz > 0 ? +(Number(stats.feat) / Number(stats.biz) * 100).toFixed(2) : 0;
+
+  const html = `<div style="font-family:Georgia,serif;max-width:580px;color:#1a1815;background:#faf6ee;padding:32px">
+<div style="font-size:9px;letter-spacing:.4em;text-transform:uppercase;color:#8a6d3b;font-family:Helvetica,sans-serif;font-weight:500">The Corridor desk · ${new Date().toLocaleDateString('en-US',{weekday:'long',month:'long',day:'numeric'})}</div>
+<h1 style="font-family:Georgia,serif;font-style:italic;font-weight:500;font-size:30px;margin:14px 0 8px;line-height:1.05">Corpus is at <span style="color:#6a3a1a">${fmt(stats.feat)}</span> features.</h1>
+
+<table style="border-collapse:collapse;font-size:14px;margin-top:18px">
+  <tr><td style="padding:4px 14px 4px 0;color:#888;width:140px">Businesses</td><td><b>${fmt(stats.biz)}</b></td></tr>
+  <tr><td style="padding:4px 14px 4px 0;color:#888">Features (total)</td><td><b>${fmt(stats.feat)}</b> · ${covPct}% coverage</td></tr>
+  <tr><td style="padding:4px 14px 4px 0;color:#888">Published</td><td>${fmt(stats.pub)}</td></tr>
+  <tr><td style="padding:4px 14px 4px 0;color:#888">Drafts pending review</td><td>${fmt(stats.draft)}</td></tr>
+  <tr><td style="padding:4px 14px 4px 0;color:#888">Generated last 24h</td><td>${fmt(stats.gen_24h)}</td></tr>
+  <tr><td style="padding:4px 14px 4px 0;color:#888">Published last 24h</td><td>${fmt(stats.pub_24h)}</td></tr>
+  ${Number(dupes.groups) > 0 ? `<tr><td style="padding:4px 14px 4px 0;color:#888">Duplicate-headline groups</td><td>${dupes.groups} (${dupes.affected} features) — auto-dedupe runs 3:33 AM</td></tr>` : ''}
+  ${Number(stats.fb_unread) > 0 ? `<tr><td style="padding:4px 14px 4px 0;color:#888">Unread feedback</td><td><b style="color:#b66565">${fmt(stats.fb_unread)}</b></td></tr>` : ''}
+  ${Number(stats.inq_open) > 0 ? `<tr><td style="padding:4px 14px 4px 0;color:#888">Open sponsor inquiries</td><td><b style="color:#b66565">${fmt(stats.inq_open)}</b></td></tr>` : ''}
+</table>
+
+${cover ? `<div style="margin-top:24px;padding:16px 20px;background:rgba(184,153,104,0.10);border:1px solid #d8cdb8;border-left:3px solid #8a6d3b">
+  <div style="font-size:9px;letter-spacing:.4em;text-transform:uppercase;color:#8a6d3b;font-weight:500;margin-bottom:6px">Cover this month</div>
+  <div style="font-family:Georgia,serif;font-style:italic;font-size:18px">${esc(cover.headline)}</div>
+  <div style="font-size:11px;color:#888;margin-top:4px">${esc(cover.biz)}</div>
+</div>` : ''}
+
+${top.length ? `<h3 style="font-family:Georgia,serif;font-style:italic;color:#6a3a1a;margin-top:24px;font-size:18px">Most-tapped published</h3>
+<ol style="font-size:13px;line-height:1.6;font-family:Georgia,serif;font-style:italic;padding-left:24px">
+  ${top.map(t => `<li><a href="http://127.0.0.1:9780/magazine/${t.id}" style="color:#1a1815">${esc(t.headline)}</a> <span style="color:#888;font-style:normal;font-size:11px">${esc(t.name)}</span></li>`).join('')}
+</ol>` : ''}
+
+${fresh.length ? `<h3 style="font-family:Georgia,serif;font-style:italic;color:#6a3a1a;margin-top:18px;font-size:18px">Latest gen</h3>
+<ul style="font-size:13px;line-height:1.6;font-family:Georgia,serif;font-style:italic;padding-left:24px">
+  ${fresh.map(f => `<li>${esc(f.headline)} <span style="color:#888;font-style:normal;font-size:11px">${esc(f.name)}</span></li>`).join('')}
+</ul>` : ''}
+
+<div style="margin-top:24px;padding-top:14px;border-top:1px solid #d8cdb8;font-size:11px;color:#6e6356;font-family:Helvetica,sans-serif">
+  <a href="http://127.0.0.1:9780/today.html" style="color:#8a6d3b">today</a> ·
+  <a href="http://127.0.0.1:9780/health.html" style="color:#8a6d3b">health</a> ·
+  <a href="http://127.0.0.1:9780/coverage.html" style="color:#8a6d3b">coverage</a> ·
+  <a href="http://127.0.0.1:9780/issue" style="color:#8a6d3b">issue</a>
+</div>
+</div>`;
+
+  if (DRY) { process.stdout.write(html); await pool.end(); return; }
+
+  const r = await fetch(GEORGE, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json', Authorization: AUTH },
+    body: JSON.stringify({ to: TO, subject: `🪶 The Corridor · ${stats.feat} features (${covPct}% coverage)`, body: html, isHtml: true, account: 'info' })
+  });
+  const out = await r.json().catch(() => ({}));
+  console.log(`[corpus_summary] ${r.status} → ${TO} · messageId=${out.messageId || 'none'}`);
+  await pool.end();
+}
+
+main().catch(e => { console.error(e); process.exit(1); });

← e145ce4 fix: rebuild ventura_corridor schema — backfill ledger 003-0  ·  back to Ventura Corridor  ·  fix: migration 018 — adds magazine_features.taps + taps_brea 64555b1 →