[object Object]

← back to Ventura Corridor

feat(news): news_count badge on /chains.html bar rows

4cada626b3a380cff7fc15c1f5ae04b27beea831 · 2026-05-07 14:25:04 -0700 · SteveStudio2

Chain-store roster now flags any chain whose locations have scraped
news/blog posts on file. Aggregates news_count across every business_id
that maps to the chain's normalized name (so Coffee Bean across 2
locations = 2, not 1).

API change:
  /api/chains   +news_count INT per chain (sum across all chain locations)
                via with_news CTE joining grouped.ids[] against news_items

UI change:
  public/chains.html bar-row    new gold-border .news-badge "narrow N"
                                appended to chain name when news_count > 0
                                clicks through to /news.html

Cache: 5-minute server-side cache means new news lands on the chains
view within 5 minutes of summarizer/scraper completion.

3 of 60 chains currently flag (Coffee Bean Tea Leaf, Leading Tax Group,
300 Data Recovery). Grows automatically with the nightly 03:15 scrape.

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

Files touched

Diff

commit 4cada626b3a380cff7fc15c1f5ae04b27beea831
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Thu May 7 14:25:04 2026 -0700

    feat(news): news_count badge on /chains.html bar rows
    
    Chain-store roster now flags any chain whose locations have scraped
    news/blog posts on file. Aggregates news_count across every business_id
    that maps to the chain's normalized name (so Coffee Bean across 2
    locations = 2, not 1).
    
    API change:
      /api/chains   +news_count INT per chain (sum across all chain locations)
                    via with_news CTE joining grouped.ids[] against news_items
    
    UI change:
      public/chains.html bar-row    new gold-border .news-badge "narrow N"
                                    appended to chain name when news_count > 0
                                    clicks through to /news.html
    
    Cache: 5-minute server-side cache means new news lands on the chains
    view within 5 minutes of summarizer/scraper completion.
    
    3 of 60 chains currently flag (Coffee Bean Tea Leaf, Leading Tax Group,
    300 Data Recovery). Grows automatically with the nightly 03:15 scrape.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 public/chains.html  |  7 ++++++-
 src/server/index.ts | 12 ++++++++++--
 2 files changed, 16 insertions(+), 3 deletions(-)

diff --git a/public/chains.html b/public/chains.html
index 3ee4c63..62d79fe 100644
--- a/public/chains.html
+++ b/public/chains.html
@@ -58,6 +58,8 @@
   .bar-row .ct { font-family: var(--serif); font-style: italic; font-size: 18px; color: var(--metal); text-align: right; font-variant-numeric: tabular-nums; }
   .bar-row.parking .nm { color: var(--metal-glow); }
   .bar-row .cities { font-family: var(--mono); font-size: 8px; letter-spacing: .12em; color: var(--ink-mute); display: block; margin-top: 2px; }
+  .bar-row .news-badge { display: inline-block; padding: 1px 6px; border: 1px solid var(--metal-glow); color: var(--metal-glow); font-size: 9px; letter-spacing: .12em; text-decoration: none; vertical-align: middle; margin-left: 4px; }
+  .bar-row .news-badge:hover { background: var(--metal-glow); color: var(--noir); }
   footer {
     padding: 24px 40px 40px; border-top: 1px solid var(--rule);
     font-family: var(--serif); font-style: italic; color: var(--ink-mute); font-size: 12px; text-align: center;
@@ -126,9 +128,12 @@ fetch('/api/chains').then(r => r.json()).then(d => {
     const isPark = PARK_RE.test(r.name);
     const w = (Number(r.n_locations) / max * 100).toFixed(1);
     const cities = (r.cities || []).slice(0, 4).map(c => c.toLowerCase().replace(/\b\w/g, m => m.toUpperCase())).join(' · ');
+    const newsBadge = (r.news_count > 0)
+      ? `<a href="/news.html" class="news-badge" title="${r.news_count} scraped news/blog post${r.news_count === 1 ? '' : 's'} across this chain's locations">📰 ${r.news_count}</a>`
+      : '';
     return `<div class="bar-row${isPark ? ' parking' : ''}">
       <div>
-        <div class="nm">${escapeHtml(r.name)}</div>
+        <div class="nm">${escapeHtml(r.name)} ${newsBadge}</div>
         <span class="cities">${escapeHtml(cities)}</span>
       </div>
       <div class="meter"><i style="width:${w}%"></i></div>
diff --git a/src/server/index.ts b/src/server/index.ts
index 07a7e1c..e4e38ff 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -4901,9 +4901,17 @@ app.get('/api/chains', async (_req, res) => {
         FROM normed
         GROUP BY nname
         HAVING count(*) >= 2
+      ),
+      with_news AS (
+        SELECT g.*,
+               COALESCE(
+                 (SELECT COUNT(*)::int FROM news_items WHERE business_id = ANY(g.ids)),
+                 0
+               ) AS news_count
+        FROM grouped g
       )
-      SELECT nname AS name, n_locations, variants, cities, sample_category
-      FROM grouped
+      SELECT nname AS name, n_locations, variants, cities, sample_category, news_count
+      FROM with_news
       ORDER BY n_locations DESC, nname ASC
       LIMIT 60
     `);

← d444435 fix(generate_features): tighten banned-phrase regex to catch  ·  back to Ventura Corridor  ·  feat(news): RSS feed at /news/feed.xml + auto-discovery on / b329ecf →