← back to Ventura Corridor
feat(news): per-business news rail on map detail panel + news_count column
8afd939fa04c4309a2bebe834bb9449256fceda5 · 2026-05-07 12:33:24 -0700 · SteveStudio2
Closes the discoverability loop: when Steve clicks any pin on the
corridor map, if that business has scraped news_items, a small rail
shows the latest 4 article titles + dates with click-through to the
source URL. Subtle gold tint background keeps it editorial.
API changes:
/api/businesses +news_count INT per row
(LATERAL count from news_items)
/api/businesses/:id +news[] last-10 articles
(id, source_url, title, excerpt,
summary, published_guess, fetched_at)
UI changes:
public/index.html showPanel() new "News from their site" rail
below contacts; only renders when
news_items > 0; capped at 4 visible
with count in the header
Reach: 45 of the 153 sited businesses currently have at least one
scraped article (29 percent), so the rail surfaces meaningfully on
every fourth pin click. Grows automatically with the nightly scrape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M public/index.htmlM src/server/index.ts
Diff
commit 8afd939fa04c4309a2bebe834bb9449256fceda5
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Thu May 7 12:33:24 2026 -0700
feat(news): per-business news rail on map detail panel + news_count column
Closes the discoverability loop: when Steve clicks any pin on the
corridor map, if that business has scraped news_items, a small rail
shows the latest 4 article titles + dates with click-through to the
source URL. Subtle gold tint background keeps it editorial.
API changes:
/api/businesses +news_count INT per row
(LATERAL count from news_items)
/api/businesses/:id +news[] last-10 articles
(id, source_url, title, excerpt,
summary, published_guess, fetched_at)
UI changes:
public/index.html showPanel() new "News from their site" rail
below contacts; only renders when
news_items > 0; capped at 4 visible
with count in the header
Reach: 45 of the 153 sited businesses currently have at least one
scraped article (29 percent), so the rail surfaces meaningfully on
every fourth pin click. Grows automatically with the nightly scrape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
public/index.html | 17 +++++++++++++++++
src/server/index.ts | 16 +++++++++++++++-
2 files changed, 32 insertions(+), 1 deletion(-)
diff --git a/public/index.html b/public/index.html
index 40f068f..2580f3b 100644
--- a/public/index.html
+++ b/public/index.html
@@ -312,6 +312,22 @@ async function showPanel(id) {
? `<a href="${b.website || '#'}" target="_blank" rel="noopener" style="display:block;margin-bottom:14px;border:1px solid var(--rule);overflow:hidden;line-height:0"><img src="${shotUrl}" alt="${b.name} front-page screenshot" style="width:100%;height:auto;display:block;transition:opacity 220ms ease" onload="this.style.opacity=1" onerror="this.style.display='none'"></a>`
: '';
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 newsBlock = newsItems.length ? (() => {
+ const items = newsItems.slice(0, 4).map(n => {
+ const dt = n.published_guess || n.fetched_at;
+ const dateStr = dt ? new Date(dt).toLocaleDateString('en-US', { month:'short', day:'numeric' }) : '';
+ const title = (n.title || '(untitled)').slice(0, 90);
+ const safeTitle = title.replace(/[<>"']/g, c => ({'<':'<','>':'>','"':'"',"'":'''}[c]));
+ return `<a href="${n.source_url}" target="_blank" rel="noopener noreferrer" style="display:block;padding:6px 0;border-bottom:1px dotted var(--rule);text-decoration:none;color:var(--ink)"><div style="font-size:12px;line-height:1.35">${safeTitle}</div><div style="font-size:9px;color:var(--ink-mute);font-family:'JetBrains Mono',monospace;letter-spacing:.16em;margin-top:2px;text-transform:uppercase">${dateStr}</div></a>`;
+ }).join('');
+ return `
+ <div style="margin:14px 0 0;padding:10px 12px;background:rgba(184,153,104,0.08);border:1px solid var(--rule)">
+ <div style="font-family:'JetBrains Mono',monospace;font-size:9px;letter-spacing:.28em;text-transform:uppercase;color:var(--metal);margin-bottom:6px">📰 News from their site · ${newsItems.length}</div>
+ ${items}
+ </div>`;
+ })() : '';
const contactLines = (r.contacts || []).map(c => {
const platform = (c.notes === 'instagram' ? 'Instagram' :
c.notes === 'tiktok' ? 'TikTok' :
@@ -335,6 +351,7 @@ async function showPanel(id) {
<div class="pn-row"><span class="k">Rank in category</span><span class="v">${s && s.category_rank ? `${s.category_rank} / ${s.category_n}` : '—'}</span></div>
<div class="pn-row"><span class="k">Last audit</span><span class="v">${a ? new Date(a.audited_at).toLocaleDateString() : '— not audited'}</span></div>
${contactLines}
+ ${newsBlock}
${websiteHtml}
`;
} catch (e) {
diff --git a/src/server/index.ts b/src/server/index.ts
index 3e644ec..3b12f64 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -215,7 +215,8 @@ app.get('/api/businesses', async (req, res) => {
b.lat, b.lng, b.phone, b.website, b.on_corridor, b.corridor_block,
s.total AS seo_score, s.tier AS seo_tier, s.category_rank, s.category_n,
e.ownership_class, e.parent_brand, e.is_franchise, e.is_chain,
- a.screenshot_path
+ a.screenshot_path,
+ COALESCE(n.news_count, 0)::int AS news_count
FROM businesses b
LEFT JOIN LATERAL (
SELECT total, tier, category_rank, category_n
@@ -230,6 +231,9 @@ app.get('/api/businesses', async (req, res) => {
WHERE business_id = b.id AND screenshot_path IS NOT NULL AND error_message IS NULL
ORDER BY audited_at DESC LIMIT 1
) a ON TRUE
+ LEFT JOIN LATERAL (
+ SELECT COUNT(*) AS news_count FROM news_items WHERE business_id = b.id
+ ) n ON TRUE
WHERE ${where.join(' AND ')}
ORDER BY b.corridor_block ASC NULLS LAST, b.name ASC
LIMIT $${params.length}
@@ -505,12 +509,22 @@ app.get('/api/businesses/:id', async (req, res) => {
FROM business_contacts WHERE business_id = $1
ORDER BY confidence DESC NULLS LAST, role ASC`, [id],
);
+ // News items scraped from this business's website. Magazine surface uses
+ // these to show "what's new from this vendor" on the profile detail.
+ const n = await query(
+ `SELECT id, source_url, title, excerpt, summary, published_guess, fetched_at
+ FROM news_items WHERE business_id = $1
+ ORDER BY COALESCE(published_guess, fetched_at::date) DESC,
+ fetched_at DESC
+ LIMIT 10`, [id]
+ );
res.json({
business: b.rows[0],
latest_audit: a.rows[0] || null,
latest_score: s.rows[0] || null,
enrichment: e.rows[0] || null,
contacts: c.rows,
+ news: n.rows,
});
} catch (e: any) {
res.status(500).json({ error: e.message });
← 00f39c0 fix(generate_features): post-filter retry on banned headline
·
back to Ventura Corridor
·
feat: apply_exa_websites.ts — merge Exa-discovered website J fd03767 →