[object Object]

← back to Ventura Corridor

feat(news): recency badge (Hot/Fresh/Stale) on map detail panel

67f75146831ea921109eed13a918efa9fd836ecf · 2026-05-07 19:11:39 -0700 · SteveStudio2

Editors can spot active corridor publishers at a glance instead of
scrolling the full news rail. Tiered visual signal:

  Hot    fresh_7d >= 3   solid gold-glow background, "🔥 HOT"
  Fresh  fresh_7d >= 1   gold outline, "📰 FRESH"
  Stale  fresh_30d >= 1  muted gray outline, "STALE"
  (no badge if no news in last 30 days)

API change:
  /api/businesses/:id     +news_stats {fresh_7d, fresh_30d, last_at}
                          single COUNT(*) FILTER query, no extra LATERAL

UI change:
  public/index.html showPanel    badge appended to .pn-name h3 inline

All 46 news-having corridor businesses currently flag — the nightly
03:15 scrape lands fresh items in news_items so the badges decay
naturally as articles age out of the 7-day / 30-day windows.

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

Files touched

Diff

commit 67f75146831ea921109eed13a918efa9fd836ecf
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Thu May 7 19:11:39 2026 -0700

    feat(news): recency badge (Hot/Fresh/Stale) on map detail panel
    
    Editors can spot active corridor publishers at a glance instead of
    scrolling the full news rail. Tiered visual signal:
    
      Hot    fresh_7d >= 3   solid gold-glow background, "🔥 HOT"
      Fresh  fresh_7d >= 1   gold outline, "📰 FRESH"
      Stale  fresh_30d >= 1  muted gray outline, "STALE"
      (no badge if no news in last 30 days)
    
    API change:
      /api/businesses/:id     +news_stats {fresh_7d, fresh_30d, last_at}
                              single COUNT(*) FILTER query, no extra LATERAL
    
    UI change:
      public/index.html showPanel    badge appended to .pn-name h3 inline
    
    All 46 news-having corridor businesses currently flag — the nightly
    03:15 scrape lands fresh items in news_items so the badges decay
    naturally as articles age out of the 7-day / 30-day windows.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 public/index.html   | 18 +++++++++++++++++-
 src/server/index.ts | 10 ++++++++++
 2 files changed, 27 insertions(+), 1 deletion(-)

diff --git a/public/index.html b/public/index.html
index 9277400..d476523 100644
--- a/public/index.html
+++ b/public/index.html
@@ -314,6 +314,22 @@ async function showPanel(id) {
     const ownerLine = r.enrichment ? `<div class="pn-row"><span class="k">Ownership</span><span class="v metal">${r.enrichment.ownership_class}${r.enrichment.parent_brand ? ' · ' + r.enrichment.parent_brand : ''}</span></div>` : '';
     // News rail — surfaces what was scraped from this business's own site.
     const newsItems = r.news || [];
+    const newsStats = r.news_stats || { fresh_7d: 0, fresh_30d: 0, last_at: null };
+    // Recency lozenge in the panel header — visible at-a-glance signal of
+    // an active publisher. "Hot" if 3+ in 7 days, "Fresh" if any in 7 days,
+    // "Stale" if any in 30 days but none recent. No badge if all news older.
+    const recencyBadge = (() => {
+      if (newsStats.fresh_7d >= 3) {
+        return `<span style="display:inline-block;margin:0 0 0 8px;padding:2px 8px;font-size:9px;letter-spacing:.2em;text-transform:uppercase;color:#0a0a0c;background:var(--metal-glow);border-radius:2px" title="${newsStats.fresh_7d} articles in the last 7 days">🔥 Hot</span>`;
+      }
+      if (newsStats.fresh_7d >= 1) {
+        return `<span style="display:inline-block;margin:0 0 0 8px;padding:2px 8px;font-size:9px;letter-spacing:.2em;text-transform:uppercase;color:var(--metal);border:1px solid var(--metal)" title="${newsStats.fresh_7d} articles in the last 7 days">📰 Fresh</span>`;
+      }
+      if (newsStats.fresh_30d >= 1) {
+        return `<span style="display:inline-block;margin:0 0 0 8px;padding:2px 8px;font-size:9px;letter-spacing:.2em;text-transform:uppercase;color:var(--ink-mute);border:1px solid var(--rule)" title="${newsStats.fresh_30d} articles in the last 30 days">Stale</span>`;
+      }
+      return '';
+    })();
     // Defensive: source_url comes from scraped HTML on third-party sites.
     // Validate scheme (http/https only — no javascript:/data:) and HTML-escape
     // all attribute values before interpolating into href. Caught by codex-2way
@@ -357,7 +373,7 @@ async function showPanel(id) {
     body.innerHTML = `
       ${shotHtml}
       <p class="pn-eyebrow">${b.category || 'Uncategorized'}</p>
-      <h3 class="pn-name">${b.name}</h3>
+      <h3 class="pn-name">${b.name}${recencyBadge}</h3>
       <p class="pn-meta">${[b.address, b.city, b.zip].filter(Boolean).join(' · ')}</p>
       ${ownerLine}
       <div class="pn-row"><span class="k">Block</span><span class="v">${b.corridor_block ?? '—'}</span></div>
diff --git a/src/server/index.ts b/src/server/index.ts
index 0890155..97e268a 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -520,6 +520,15 @@ app.get('/api/businesses/:id', async (req, res) => {
                  fetched_at DESC
         LIMIT 10`, [id]
     );
+    // Recency stats — "fresh" = fetched within the last 7 days, used by the
+    // map's recency badge so editors can spot active publishers at a glance.
+    const ns = await query(
+      `SELECT
+         COUNT(*) FILTER (WHERE fetched_at > now() - interval '7 days')::int AS fresh_7d,
+         COUNT(*) FILTER (WHERE fetched_at > now() - interval '30 days')::int AS fresh_30d,
+         MAX(fetched_at) AS last_at
+       FROM news_items WHERE business_id = $1`, [id]
+    );
     res.json({
       business: b.rows[0],
       latest_audit: a.rows[0] || null,
@@ -527,6 +536,7 @@ app.get('/api/businesses/:id', async (req, res) => {
       enrichment: e.rows[0] || null,
       contacts: c.rows,
       news: n.rows,
+      news_stats: ns.rows[0] || { fresh_7d: 0, fresh_30d: 0, last_at: null },
     });
   } catch (e: any) {
     res.status(500).json({ error: e.message });

← 3b4d523 feat(news): per-business RSS via /news/feed.xml?business=N  ·  back to Ventura Corridor  ·  feat(news): /api/news/leaderboard + Top publishers section o 0f24227 →