[object Object]

← back to Ventura Corridor

feat(news): /api/news/leaderboard + Top publishers section on /coverage.html

0f24227a266357bdb32789403a1b48a7ae5770e9 · 2026-05-07 19:54:30 -0700 · SteveStudio2

Surfaces the corridors most-active publishers — businesses whose blogs/
news pages are actually being maintained vs dormant. Pairs with the
recency badge from tick 52 — the same Hot threshold ( fresh_7d >= 3 )
gets a red flame in front of the name on the leaderboard.

Routes:
  GET /api/news/leaderboard    {id, name, city, category, total,
                                fresh_7d, fresh_30d, last_at} per biz,
                                sorted fresh_7d DESC, fresh_30d DESC,
                                total DESC. 5-min cache. Cap 50.

UI:
  /coverage.html   new "Top publishers" bar-section after the heatmap.
                   Shows rank, name (clickable -> /business/:id/news),
                   city + category, 7d count, 30d count, last-fetched
                   date. Top 20 displayed.

Real numbers: 10 corridor businesses currently flag Hot, 36 more flag
Fresh. Leaderboard refreshes daily at 03:15 with the next scrape.

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

Files touched

Diff

commit 0f24227a266357bdb32789403a1b48a7ae5770e9
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Thu May 7 19:54:30 2026 -0700

    feat(news): /api/news/leaderboard + Top publishers section on /coverage.html
    
    Surfaces the corridors most-active publishers — businesses whose blogs/
    news pages are actually being maintained vs dormant. Pairs with the
    recency badge from tick 52 — the same Hot threshold ( fresh_7d >= 3 )
    gets a red flame in front of the name on the leaderboard.
    
    Routes:
      GET /api/news/leaderboard    {id, name, city, category, total,
                                    fresh_7d, fresh_30d, last_at} per biz,
                                    sorted fresh_7d DESC, fresh_30d DESC,
                                    total DESC. 5-min cache. Cap 50.
    
    UI:
      /coverage.html   new "Top publishers" bar-section after the heatmap.
                       Shows rank, name (clickable -> /business/:id/news),
                       city + category, 7d count, 30d count, last-fetched
                       date. Top 20 displayed.
    
    Real numbers: 10 corridor businesses currently flag Hot, 36 more flag
    Fresh. Leaderboard refreshes daily at 03:15 with the next scrape.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 public/coverage.html | 33 +++++++++++++++++++++++++++++++++
 src/server/index.ts  | 25 +++++++++++++++++++++++++
 2 files changed, 58 insertions(+)

diff --git a/public/coverage.html b/public/coverage.html
index 882fedd..2b82ed0 100644
--- a/public/coverage.html
+++ b/public/coverage.html
@@ -90,6 +90,12 @@ main{padding:32px;max-width:1200px;margin:0 auto}
   <div id="news-heat"></div>
 </div>
 
+<div class="bar-section">
+  <h3>Top publishers · fresh in the last 7 days</h3>
+  <p style="font-family:'Cormorant Garamond',serif;font-style:italic;font-size:13px;color:var(--ink-mute);margin:0 0 14px">Most active corridor publishers — sorted by article count in the last 7 days, then 30 days, then all-time. The 🔥 marker matches the map's "Hot" recency badge (3+ in 7 days).</p>
+  <div id="news-board"></div>
+</div>
+
 </main>
 <script>
 function escHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
@@ -159,6 +165,33 @@ async function load() {
     document.getElementById('news-heat').innerHTML = '<p style="color:#c47b7b">news heatmap unavailable</p>';
   }
 
+  // Leaderboard
+  try {
+    const lb = await fetch('/api/news/leaderboard').then(r => r.json());
+    const lbRows = lb.rows || [];
+    const board = document.getElementById('news-board');
+    if (!lbRows.length) {
+      board.innerHTML = '<p style="color:var(--ink-mute);font-style:italic;font-family:Cormorant Garamond,serif">No news yet.</p>';
+    } else {
+      const fmtDate = (s) => s ? new Date(s).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—';
+      board.innerHTML = lbRows.slice(0, 20).map((r, i) => {
+        const hot = r.fresh_7d >= 3 ? '🔥 ' : '';
+        return `<div class="bar-row" style="grid-template-columns:30px 1fr 60px 60px 80px">
+          <span style="font-family:var(--mono);font-size:9px;color:var(--ink-mute)">#${i + 1}</span>
+          <a href="/business/${r.id}/news" style="color:var(--ink);text-decoration:none">
+            <span class="name">${hot}${escHtml(r.name)}</span>
+            <span class="cities">${escHtml((r.city || '').toLowerCase().replace(/\b\w/g, m => m.toUpperCase()))}${r.category ? ' · ' + escHtml(r.category) : ''}</span>
+          </a>
+          <span class="v" style="font-family:var(--mono);font-size:11px;color:var(--metal-glow)">${r.fresh_7d} <span style="color:var(--ink-mute);font-size:9px">/7d</span></span>
+          <span class="v" style="font-family:var(--mono);font-size:11px;color:var(--metal)">${r.fresh_30d} <span style="color:var(--ink-mute);font-size:9px">/30d</span></span>
+          <span class="v" style="font-family:var(--mono);font-size:9px;color:var(--ink-mute)">${fmtDate(r.last_at)}</span>
+        </div>`;
+      }).join('');
+    }
+  } catch (e) {
+    document.getElementById('news-board').innerHTML = '<p style="color:#c47b7b">leaderboard 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 97e268a..c8aad64 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -5081,6 +5081,31 @@ app.get('/api/news/recent', async (req, res) => {
   }
 });
 
+// Top publishers by recency. Editors use this to spot active vendors
+// (whose blogs are actually maintained) vs dormant ones. Default sort
+// "fresh_7d desc, fresh_30d desc, total desc" so this week's most active
+// rises to the top. Cap 50.
+app.get('/api/news/leaderboard', async (_req, res) => {
+  try {
+    const r = await query(`
+      SELECT b.id, b.name, b.city, b.category, b.website,
+             COUNT(*)::int AS total,
+             COUNT(*) FILTER (WHERE n.fetched_at > now() - interval '7 days')::int AS fresh_7d,
+             COUNT(*) FILTER (WHERE n.fetched_at > now() - interval '30 days')::int AS fresh_30d,
+             MAX(n.fetched_at) AS last_at
+        FROM news_items n
+        JOIN businesses b ON b.id = n.business_id
+       GROUP BY b.id, b.name, b.city, b.category, b.website
+       ORDER BY fresh_7d DESC, fresh_30d DESC, total DESC, b.name ASC
+       LIMIT 50
+    `);
+    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 });
+  }
+});
+
 // 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.

← 67f7514 feat(news): recency badge (Hot/Fresh/Stale) on map detail pa  ·  back to Ventura Corridor  ·  feat(news): summarizer pre-flight + 4-hour poll launchd 4b74df3 →