[object Object]

← back to Ventura Corridor

feat(news): /this-week.html editorial digest + /api/news/digest

e45334ec6190d307d2bdeff81d2594fa6acacf40 · 2026-05-07 11:59:49 -0700 · SteveStudio2

Editorial front-door for the news pipeline. Where /news.html is the
firehose, /this-week.html is the curated read — 3 lead stories at the
top with serif/italic display type matching magazine.html, then a
two-column "also this week" tail of up to 30 more dispatches.

Routes:
  GET /api/news/digest    {lead:[3], tail:[<=30], generated_at}
                          5-min cache, lead = articles with body >= 200
                          chars + non-trivial title; tail = remainder
                          most-recent-first
  GET /this-week.html     paper aesthetic, double-rule masthead, mono
                          kickers, Cormorant Garamond italic display

Both paths whitelisted in ADMIN_PATHS so basic-auth gates them
consistent with the rest of the corridor admin tools.

Pulls from the same news_items table populated by the news scraper,
so it works today on the 76 articles already in DB and grows
automatically as the nightly 03:15 scrape lands more.

No external API deps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit e45334ec6190d307d2bdeff81d2594fa6acacf40
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Thu May 7 11:59:49 2026 -0700

    feat(news): /this-week.html editorial digest + /api/news/digest
    
    Editorial front-door for the news pipeline. Where /news.html is the
    firehose, /this-week.html is the curated read — 3 lead stories at the
    top with serif/italic display type matching magazine.html, then a
    two-column "also this week" tail of up to 30 more dispatches.
    
    Routes:
      GET /api/news/digest    {lead:[3], tail:[<=30], generated_at}
                              5-min cache, lead = articles with body >= 200
                              chars + non-trivial title; tail = remainder
                              most-recent-first
      GET /this-week.html     paper aesthetic, double-rule masthead, mono
                              kickers, Cormorant Garamond italic display
    
    Both paths whitelisted in ADMIN_PATHS so basic-auth gates them
    consistent with the rest of the corridor admin tools.
    
    Pulls from the same news_items table populated by the news scraper,
    so it works today on the 76 articles already in DB and grows
    automatically as the nightly 03:15 scrape lands more.
    
    No external API deps.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 public/this-week.html | 153 ++++++++++++++++++++++++++++++++++++++++++++++++++
 src/server/index.ts   |  37 ++++++++++++
 2 files changed, 190 insertions(+)

diff --git a/public/this-week.html b/public/this-week.html
new file mode 100644
index 0000000..bc7ca56
--- /dev/null
+++ b/public/this-week.html
@@ -0,0 +1,153 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>This week on the Boulevard · Ventura 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,300;0,400;0,500;0,600;1,300;1,400;1,500&family=Inter:wght@200;300;400;500;600&family=JetBrains+Mono:wght@200;300;400&display=swap');
+:root {
+  --paper:#faf6ee;--ink:#1a1815;--ink-mute:#6e6356;--metal:#8a6d3b;
+  --metal-glow:#b89968;--accent:#6a3a1a;--rule:#d8cdb8;
+  --serif:'Cormorant Garamond',Georgia,serif;--sans:'Inter',sans-serif;--mono:'JetBrains Mono',monospace;
+}
+html,body{margin:0;background:var(--paper);color:var(--ink);font-family:var(--sans);font-weight:300}
+*{box-sizing:border-box}
+a{color:var(--ink);text-decoration:none}
+a:hover{color:var(--accent)}
+
+header.masthead{text-align:center;padding:56px 24px 32px;border-bottom:4px double var(--ink)}
+.masthead .kicker{font-weight:500;font-size:10px;letter-spacing:.4em;text-transform:uppercase;color:var(--metal);margin-bottom:14px}
+.masthead h1{font-family:var(--serif);font-style:italic;font-weight:500;font-size:64px;line-height:.95;margin:0;letter-spacing:-.02em}
+.masthead h1 em{font-style:normal;color:var(--metal)}
+.masthead .deck{font-family:var(--serif);font-weight:300;font-style:italic;font-size:17px;color:var(--ink-mute);margin-top:14px;max-width:600px;margin-left:auto;margin-right:auto;line-height:1.5}
+.masthead .issue{margin-top:20px;font-family:var(--mono);font-size:9px;letter-spacing:.3em;text-transform:uppercase;color:var(--ink-mute)}
+nav.crossnav{padding:14px 32px;border-bottom:1px solid var(--rule);display:flex;gap:14px;flex-wrap:wrap;justify-content:center;font-family:var(--mono);font-size:9px;letter-spacing:.28em;text-transform:uppercase}
+nav.crossnav a{color:var(--ink-mute);padding:5px 12px;border:1px solid var(--rule)}
+nav.crossnav a:hover,nav.crossnav a.active{color:var(--metal);border-color:var(--metal)}
+
+main{max-width:1100px;margin:0 auto;padding:48px 32px}
+
+.lead-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:32px;margin-bottom:64px;padding-bottom:48px;border-bottom:1px solid var(--rule)}
+@media (max-width:840px){.lead-grid{grid-template-columns:1fr}}
+.lead{display:flex;flex-direction:column;gap:12px}
+.lead .biz{font-family:var(--mono);font-size:9px;letter-spacing:.28em;text-transform:uppercase;color:var(--metal)}
+.lead h2{font-family:var(--serif);font-style:italic;font-weight:500;font-size:30px;line-height:1.15;margin:0;color:var(--ink)}
+.lead .body{font-family:var(--serif);font-size:15px;line-height:1.55;color:var(--ink-mute);margin:0}
+.lead .body em{color:var(--accent);font-style:italic}
+.lead .meta{font-family:var(--mono);font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-mute);margin-top:auto;padding-top:14px;border-top:1px solid var(--rule);display:flex;justify-content:space-between;gap:14px}
+.lead .meta a{color:var(--metal);border-bottom:1px dotted var(--metal)}
+.lead a.read-more{color:var(--accent);font-family:var(--sans);font-weight:500;font-size:12px;letter-spacing:.08em;text-transform:uppercase}
+
+.tail-section h3{font-family:var(--serif);font-style:italic;font-weight:500;font-size:22px;margin:0 0 6px;color:var(--ink)}
+.tail-section .deck-small{font-family:var(--serif);font-weight:300;font-style:italic;font-size:13px;color:var(--ink-mute);margin:0 0 24px}
+.tail{display:grid;grid-template-columns:repeat(2,1fr);gap:18px 32px}
+@media (max-width:840px){.tail{grid-template-columns:1fr}}
+.tail-item{padding:14px 0;border-bottom:1px solid var(--rule);display:flex;flex-direction:column;gap:6px}
+.tail-item .biz{font-family:var(--mono);font-size:9px;letter-spacing:.22em;text-transform:uppercase;color:var(--metal)}
+.tail-item h4{font-family:var(--serif);font-weight:500;font-size:18px;line-height:1.25;margin:0;color:var(--ink)}
+.tail-item .meta{font-family:var(--mono);font-size:9px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-mute)}
+
+.empty{padding:80px 0;text-align:center;color:var(--ink-mute);font-family:var(--serif);font-style:italic;font-size:20px}
+
+footer.colophon{text-align:center;padding:48px 24px 64px;border-top:4px double var(--ink);color:var(--ink-mute);font-family:var(--serif);font-style:italic;font-size:13px}
+</style>
+</head>
+<body>
+
+<header class="masthead">
+  <p class="kicker">Ventura Corridor · Issue dispatched</p>
+  <h1>This <em>week</em> on the Boulevard</h1>
+  <p class="deck">Fresh signals from every storefront with a website on Ventura Bl — what was announced, posted, opened, or changed in the last seven days, served straight from the source.</p>
+  <p class="issue" id="issue-line">loading…</p>
+</header>
+
+<nav class="crossnav">
+  <a href="/">Map</a>
+  <a href="/magazine.html">Magazine</a>
+  <a href="/this-week.html" class="active">This week</a>
+  <a href="/news.html">News</a>
+  <a href="/calendar.html">Calendar</a>
+  <a href="/coverage.html">Coverage</a>
+</nav>
+
+<main>
+  <div id="content">
+    <div class="empty">Loading the boulevard's week…</div>
+  </div>
+</main>
+
+<footer class="colophon">
+  Edited by qwen3:14b on a Mac in Sherman Oaks · printed at <span id="now">…</span>
+</footer>
+
+<script>
+const escHtml = s => String(s||'').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]);
+const fmt = s => {
+  if (!s) return '';
+  const d = new Date(s);
+  if (isNaN(d.getTime())) return '';
+  return d.toLocaleDateString('en-US', { weekday:'short', month:'short', day:'numeric' });
+};
+const truncate = (s, n) => {
+  if (!s) return '';
+  return s.length > n ? s.slice(0, n - 1).replace(/\s+\S*$/, '') + '…' : s;
+};
+function hostOf(u) { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return ''; } }
+
+function renderLead(r) {
+  const text = r.summary || r.excerpt || '';
+  const date = r.published_guess || r.fetched_at;
+  return [
+    '<article class="lead">',
+      '<p class="biz">', escHtml(r.business_name||''), r.city ? ' · ' + escHtml(r.city) : '', '</p>',
+      '<h2>', escHtml(truncate(r.title || '(untitled)', 90)), '</h2>',
+      '<p class="body">', escHtml(truncate(text, 320)), '</p>',
+      '<div class="meta"><span>', escHtml(fmt(date)), '</span>',
+        '<a href="', escHtml(r.source_url), '" target="_blank" rel="noopener noreferrer">', escHtml(hostOf(r.source_url)), ' ↗</a>',
+      '</div>',
+    '</article>'
+  ].join('');
+}
+function renderTail(r) {
+  const date = r.published_guess || r.fetched_at;
+  return [
+    '<a class="tail-item" href="', escHtml(r.source_url), '" target="_blank" rel="noopener noreferrer">',
+      '<span class="biz">', escHtml(r.business_name||''), '</span>',
+      '<h4>', escHtml(truncate(r.title || '(untitled)', 100)), '</h4>',
+      '<span class="meta">', escHtml(fmt(date)), ' · ', escHtml(hostOf(r.source_url)), '</span>',
+    '</a>'
+  ].join('');
+}
+
+async function load() {
+  try {
+    const r = await fetch('/api/news/digest');
+    const d = await r.json();
+    const content = document.getElementById('content');
+    if (!d.lead || !d.lead.length) {
+      content.innerHTML = '<div class="empty">No fresh news yet. Check back after tonight\'s scrape.</div>';
+      return;
+    }
+    const total = (d.lead.length || 0) + (d.tail.length || 0);
+    document.getElementById('issue-line').textContent = total + ' stories · ' + fmt(d.generated_at);
+
+    let html = '<div class="lead-grid">' + d.lead.map(renderLead).join('') + '</div>';
+    if (d.tail.length) {
+      html += '<section class="tail-section">';
+      html += '<h3>Also this week</h3>';
+      html += '<p class="deck-small">' + d.tail.length + ' more dispatches from the corridor.</p>';
+      html += '<div class="tail">' + d.tail.map(renderTail).join('') + '</div>';
+      html += '</section>';
+    }
+    content.innerHTML = html;
+  } catch (e) {
+    document.getElementById('content').innerHTML = '<div class="empty">Could not load. Is the server up?</div>';
+  }
+}
+
+document.getElementById('now').textContent = new Date().toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' });
+load();
+</script>
+</body>
+</html>
diff --git a/src/server/index.ts b/src/server/index.ts
index 424a436..3e644ec 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -113,6 +113,7 @@ const ADMIN_PATHS = [
   /^\/buildings\/[^/]+\/issue\/?$/i,
   /^\/api\/buildings(\.csv|\/.*)?$/i,
   /^\/news(\.html)?\/?$/i,
+  /^\/this-week(\.html)?\/?$/i,
   /^\/api\/news(\/.*)?$/i,
   /^\/pitch\/\d+\/?$/i,
 ];
@@ -4988,6 +4989,42 @@ app.get('/api/news/recent', async (req, res) => {
   }
 });
 
+// Editorial digest — curated "this week on the boulevard" structure.
+// Lead 3 = newest items with a body of 200+ chars (anything shorter is a
+// thin hit-and-miss home-page slug). Tail = rest, sorted recent-first.
+app.get('/api/news/digest', async (_req, res) => {
+  try {
+    const lead = await query(`
+      SELECT n.id, n.business_id, n.source_url, n.title, n.excerpt, n.summary,
+             n.published_guess, n.fetched_at,
+             b.name AS business_name, b.category, b.city
+        FROM news_items n
+        JOIN businesses b ON b.id = n.business_id
+       WHERE length(n.body) > 200
+         AND n.title IS NOT NULL AND length(n.title) > 6
+       ORDER BY COALESCE(n.published_guess, n.fetched_at::date) DESC,
+                n.fetched_at DESC
+       LIMIT 3
+    `);
+    const tailIds = lead.rows.map((r: any) => r.id);
+    const tail = await query(`
+      SELECT n.id, n.business_id, n.source_url, n.title, n.excerpt, n.summary,
+             n.published_guess, n.fetched_at,
+             b.name AS business_name, b.category, b.city
+        FROM news_items n
+        JOIN businesses b ON b.id = n.business_id
+       WHERE n.id NOT IN (${tailIds.length ? tailIds.map((_: any, i: number) => '$' + (i + 1)).join(',') : 'NULL'})
+       ORDER BY COALESCE(n.published_guess, n.fetched_at::date) DESC,
+                n.fetched_at DESC
+       LIMIT 30
+    `, tailIds);
+    res.set('Cache-Control', 'public, max-age=300');
+    res.json({ lead: lead.rows, tail: tail.rows, generated_at: new Date().toISOString() });
+  } catch (e: any) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
 app.get('/api/news/by-business/:id', async (req, res) => {
   try {
     const id = parseInt(req.params.id, 10);

← 828ab33 feat(enrich): website-discovery resilience + category priori  ·  back to Ventura Corridor  ·  fix(generate_features): forbid 'multi-tenant building' boile e7c9465 →