← 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
M public/coverage.htmlM src/server/index.ts
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 →