← 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
M public/index.htmlM src/server/index.ts
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 →