[object Object]

← back to Ventura Corridor

feat(news): /api/news/by-vertical + heatmap on /coverage.html

ec89429eb9f5a9452ffef8e2736193c0675beda2 · 2026-05-07 16:01:14 -0700 · SteveStudio2

Surfaces which OSM categories are publishing the most blog/news/press
content. New section on /coverage.html below the existing
"By vertical / By city" coverage bars, but reflecting scraped news
volume not magazine-feature coverage.

Routes:
  GET /api/news/by-vertical   {category, top_cat, news_count, biz_count}
                              ordered by news_count DESC; CTE joins
                              news_items + businesses; 5-min cache.

UI changes:
  public/coverage.html        new "News heatmap" section with bar rows
                              (gold gradient fill); lazy-loaded after
                              the existing coverage bars so the page
                              still renders if news_items is empty;
                              graceful empty state + error handler.

Real numbers right now: amenity:restaurant 16 articles / 8 biz,
shop:hairdresser 12 / 6, office:lawyer 9 / 4, etc.

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

Files touched

Diff

commit ec89429eb9f5a9452ffef8e2736193c0675beda2
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Thu May 7 16:01:14 2026 -0700

    feat(news): /api/news/by-vertical + heatmap on /coverage.html
    
    Surfaces which OSM categories are publishing the most blog/news/press
    content. New section on /coverage.html below the existing
    "By vertical / By city" coverage bars, but reflecting scraped news
    volume not magazine-feature coverage.
    
    Routes:
      GET /api/news/by-vertical   {category, top_cat, news_count, biz_count}
                                  ordered by news_count DESC; CTE joins
                                  news_items + businesses; 5-min cache.
    
    UI changes:
      public/coverage.html        new "News heatmap" section with bar rows
                                  (gold gradient fill); lazy-loaded after
                                  the existing coverage bars so the page
                                  still renders if news_items is empty;
                                  graceful empty state + error handler.
    
    Real numbers right now: amenity:restaurant 16 articles / 8 biz,
    shop:hairdresser 12 / 6, office:lawyer 9 / 4, etc.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 public/coverage.html | 29 +++++++++++++++++++++++++++++
 src/server/index.ts  | 30 ++++++++++++++++++++++++++++++
 2 files changed, 59 insertions(+)

diff --git a/public/coverage.html b/public/coverage.html
index e84c531..882fedd 100644
--- a/public/coverage.html
+++ b/public/coverage.html
@@ -84,6 +84,12 @@ main{padding:32px;max-width:1200px;margin:0 auto}
   <div id="city"></div>
 </div>
 
+<div class="bar-section">
+  <h3>News heatmap · which categories are publishing</h3>
+  <p style="font-family:'Cormorant Garamond',serif;font-style:italic;font-size:13px;color:var(--ink-mute);margin:0 0 14px">Articles scraped from each business's own website, bucketed by OSM category. Top buckets currently producing the most blog/news/press content.</p>
+  <div id="news-heat"></div>
+</div>
+
 </main>
 <script>
 function escHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
@@ -130,6 +136,29 @@ async function load() {
   renderBars(d.by_vertical, document.getElementById('vert'));
   renderBars(d.by_city, document.getElementById('city'));
 
+  // News heatmap — pulled separately so coverage.html still renders if news data is empty.
+  try {
+    const nv = await fetch('/api/news/by-vertical').then(r => r.json());
+    const rows = nv.rows || [];
+    if (rows.length === 0) {
+      document.getElementById('news-heat').innerHTML = '<p style="color:var(--ink-mute);font-style:italic;font-family:Cormorant Garamond,serif">No scraped news yet. The 03:15 cron checks every site nightly.</p>';
+    } else {
+      const max = Math.max(1, ...rows.map(r => Number(r.news_count)));
+      document.getElementById('news-heat').innerHTML = rows.map(r => {
+        const fillW = (Number(r.news_count) / max * 100).toFixed(1);
+        return `<div class="bar-row">
+          <span class="name">${escHtml(r.category)}</span>
+          <div class="track" style="width:${fillW}%;border-color:var(--rule)">
+            <div class="filled" style="width:100%;background:linear-gradient(90deg,var(--metal),var(--metal-glow))"></div>
+          </div>
+          <span class="v"><b>${r.news_count}</b> · <span style="color:var(--metal)">${r.biz_count} biz</span></span>
+        </div>`;
+      }).join('');
+    }
+  } catch (e) {
+    document.getElementById('news-heat').innerHTML = '<p style="color:#c47b7b">news heatmap unavailable</p>';
+  }
+
   // Growth line
   const hist = await fetch('/api/coverage/history').then(r => r.json()).catch(() => ({ snapshots: [] }));
   const snaps = hist.snapshots || [];
diff --git a/src/server/index.ts b/src/server/index.ts
index 0bd59d5..75e6b45 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -5017,6 +5017,36 @@ app.get('/api/news/recent', async (req, res) => {
   }
 });
 
+// News by vertical — heatmap source for /coverage.html. Buckets by full
+// OSM-style category (e.g., "amenity: restaurant", "shop: hairdresser") and
+// reports both raw articles and distinct businesses publishing.
+app.get('/api/news/by-vertical', async (_req, res) => {
+  try {
+    const r = await query(`
+      WITH cat_news AS (
+        SELECT b.category,
+               COUNT(*) AS news_count,
+               COUNT(DISTINCT n.business_id) AS biz_count
+          FROM news_items n
+          JOIN businesses b ON b.id = n.business_id
+         WHERE b.category IS NOT NULL
+         GROUP BY b.category
+      )
+      SELECT category,
+             split_part(category, ':', 1) AS top_cat,
+             news_count::int,
+             biz_count::int
+        FROM cat_news
+       ORDER BY news_count DESC, category ASC
+       LIMIT 60
+    `);
+    res.set('Cache-Control', 'public, max-age=300');
+    res.json({ count: r.rowCount, rows: r.rows });
+  } catch (e: any) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
 // Per-business news page. When a viewer clicks the news badge from /chains,
 // /buildings, the map detail panel, or /news.html, they can drill into the
 // full firehose for that single business instead of getting the boulevard

← 5afdd26 feat(news): /business/:id/news per-business news firehose pa  ·  back to Ventura Corridor  ·  feat(news): "Latest from their site" section on /magazine/:i 33b09e9 →