[object Object]

← 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

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 => ({'<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 →