[object Object]

← back to Ventura Corridor

feat(news): /api/news/archive + /news/archive.html weekly history view

ebcb5c8a07166ff1020f4f442060ee3808ebda32 · 2026-05-07 21:31:29 -0700 · SteveStudio2

11 distinct ISO weeks of content currently flowing — articles bucketed
by published_guess (so a 2014 post lands in 2014s

Files touched

Diff

commit ebcb5c8a07166ff1020f4f442060ee3808ebda32
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Thu May 7 21:31:29 2026 -0700

    feat(news): /api/news/archive + /news/archive.html weekly history view
    
    11 distinct ISO weeks of content currently flowing — articles bucketed
    by published_guess (so a 2014 post lands in 2014s
---
 public/news/archive.html | 107 +++++++++++++++++++++++++++++++++++++++++++++++
 src/server/index.ts      |  36 ++++++++++++++++
 2 files changed, 143 insertions(+)

diff --git a/public/news/archive.html b/public/news/archive.html
new file mode 100644
index 0000000..15bf99d
--- /dev/null
+++ b/public/news/archive.html
@@ -0,0 +1,107 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>News archive · Ventura Corridor</title>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<link rel="alternate" type="application/rss+xml" title="Ventura Corridor — News from the Boulevard" href="/news/feed.xml">
+<style>
+@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;1,400;1,500&family=Inter:wght@200;300;400;500&family=JetBrains+Mono:wght@200;300&display=swap');
+:root{--noir:#0a0a0c;--noir-rise:#131316;--ink:#f0ece2;--ink-mute:#888475;--metal:#b89968;--metal-glow:#d4b683;--rule:#2a2622}
+*{box-sizing:border-box}body{margin:0;background:var(--noir);color:var(--ink);font-family:Inter,sans-serif;font-weight:300}
+header{padding:30px 32px 16px;border-bottom:1px solid var(--rule)}
+header h1{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:400;font-size:32px;margin:0}
+header h1 em{color:var(--metal)}
+header .sub{font-size:11px;letter-spacing:.26em;text-transform:uppercase;color:var(--ink-mute);margin-top:4px}
+nav{padding:10px 32px;border-bottom:1px solid var(--rule);display:flex;gap:12px;flex-wrap:wrap;font-size:9px;letter-spacing:.26em;text-transform:uppercase}
+nav a{color:var(--ink-mute);text-decoration:none;padding:5px 10px;border:1px solid var(--rule)}
+nav a:hover, nav a.active{color:var(--metal);border-color:var(--metal)}
+
+main{padding:48px 32px;max-width:1100px;margin:0 auto}
+.week{margin-bottom:48px;padding-bottom:32px;border-bottom:1px solid var(--rule)}
+.week:last-child{border-bottom:none}
+.week-head{display:flex;align-items:baseline;justify-content:space-between;margin-bottom:18px;gap:14px}
+.week-label{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:28px;color:var(--metal-glow);margin:0}
+.week-count{font-family:'JetBrains Mono',monospace;font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--ink-mute)}
+.items{display:grid;grid-template-columns:repeat(2,1fr);gap:18px 32px}
+@media (max-width:840px){.items{grid-template-columns:1fr}}
+.item{padding:14px 0;border-bottom:1px dotted var(--rule);display:flex;flex-direction:column;gap:6px}
+.item .biz{font-family:'JetBrains Mono',monospace;font-size:9px;letter-spacing:.22em;text-transform:uppercase;color:var(--metal)}
+.item .biz a{color:inherit;text-decoration:none}
+.item .biz a:hover{color:var(--metal-glow)}
+.item h4{font-family:'Cormorant Garamond',serif;font-weight:500;font-size:18px;line-height:1.25;margin:0}
+.item h4 a{color:var(--ink);text-decoration:none}
+.item h4 a:hover{color:var(--metal-glow)}
+.item .meta{font-family:'JetBrains Mono',monospace;font-size:9px;letter-spacing:.14em;color:var(--ink-mute);text-transform:uppercase}
+.empty{padding:80px 0;text-align:center;color:var(--ink-mute);font-style:italic;font-family:'Cormorant Garamond',serif;font-size:18px}
+</style>
+</head>
+<body>
+<header>
+  <h1>News <em>archive</em></h1>
+  <div class="sub">scraped articles bucketed by ISO week, newest first</div>
+</header>
+<nav>
+  <a href="/">Map</a>
+  <a href="/magazine.html">Magazine</a>
+  <a href="/this-week.html">This week</a>
+  <a href="/news.html">News</a>
+  <a href="/news/archive.html" class="active">Archive</a>
+  <a href="/coverage.html">Coverage</a>
+</nav>
+<main id="content">
+  <div class="empty">Loading…</div>
+</main>
+<script>
+const escHtml = s => String(s||'').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
+const fmtWeek = w => {
+  const d = new Date(w);
+  if (isNaN(d.getTime())) return w;
+  const end = new Date(d);
+  end.setDate(end.getDate() + 6);
+  const m = { month: 'short', day: 'numeric' };
+  return d.toLocaleDateString('en-US', m) + ' – ' + end.toLocaleDateString('en-US', m) + ', ' + d.getFullYear();
+};
+const fmtDate = s => {
+  if (!s) return '';
+  const d = new Date(s);
+  if (isNaN(d.getTime())) return '';
+  return d.toLocaleDateString('en-US', { weekday:'short', month:'short', day:'numeric' });
+};
+const safeUrl = raw => {
+  try { const u = new URL(String(raw||'')); if (u.protocol==='http:'||u.protocol==='https:') return u.href; } catch{}
+  return '#';
+};
+async function load() {
+  try {
+    const r = await fetch('/api/news/archive');
+    const d = await r.json();
+    const weeks = d.weeks || [];
+    const c = document.getElementById('content');
+    if (!weeks.length) { c.innerHTML = '<div class="empty">No archived news yet.</div>'; return; }
+    c.innerHTML = weeks.map(w => {
+      const items = (w.items || []).map(it => {
+        const date = it.date;
+        const text = it.summary || it.excerpt || '';
+        return `<div class="item">
+          <div class="biz"><a href="/business/${it.business_id}/news">${escHtml(it.business_name||'')}${it.city?' · '+escHtml(it.city):''}</a></div>
+          <h4><a href="${escHtml(safeUrl(it.source_url))}" target="_blank" rel="noopener noreferrer">${escHtml((it.title||'(untitled)').slice(0,140))}</a></h4>
+          <div class="meta">${escHtml(fmtDate(date))}</div>
+        </div>`;
+      }).join('');
+      return `<section class="week">
+        <div class="week-head">
+          <h3 class="week-label">${escHtml(fmtWeek(w.week))}</h3>
+          <span class="week-count">${w.n} article${w.n===1?'':'s'}</span>
+        </div>
+        <div class="items">${items}</div>
+      </section>`;
+    }).join('');
+  } catch(e){
+    document.getElementById('content').innerHTML = '<div class="empty">Failed to load archive.</div>';
+  }
+}
+load();
+</script>
+</body>
+</html>
diff --git a/src/server/index.ts b/src/server/index.ts
index 36dce70..82e22f0 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -114,6 +114,7 @@ const ADMIN_PATHS = [
   /^\/api\/buildings(\.csv|\/.*)?$/i,
   /^\/news(\.html)?\/?$/i,
   /^\/news\/feed\.xml\/?$/i,
+  /^\/news\/archive(\.html)?\/?$/i,
   /^\/business\/\d+\/news\/?$/i,
   /^\/this-week(\.html)?\/?$/i,
   /^\/api\/news(\/.*)?$/i,
@@ -5081,6 +5082,41 @@ app.get('/api/news/recent', async (req, res) => {
   }
 });
 
+// Weekly archive — surfaces the full historical span of scraped news_items,
+// bucketed by ISO week. Uses published_guess when available (so a 2014 post
+// stays in 2014's bin), falls back to fetched_at::date otherwise.
+app.get('/api/news/archive', async (_req, res) => {
+  try {
+    const r = await query(`
+      WITH bucketed AS (
+        SELECT n.id, n.business_id, n.source_url, n.title, n.excerpt, n.summary,
+               n.published_guess, n.fetched_at,
+               b.name AS business_name, b.city,
+               date_trunc('week', COALESCE(n.published_guess, n.fetched_at::date))::date AS week
+          FROM news_items n
+          JOIN businesses b ON b.id = n.business_id
+         WHERE COALESCE(n.published_guess, n.fetched_at::date) IS NOT NULL
+      )
+      SELECT week,
+             COUNT(*)::int AS n,
+             json_agg(json_build_object(
+               'id', id, 'business_id', business_id, 'business_name', business_name,
+               'city', city, 'title', title, 'source_url', source_url,
+               'summary', summary, 'excerpt', excerpt,
+               'date', COALESCE(published_guess, fetched_at::date)
+             ) ORDER BY COALESCE(published_guess, fetched_at::date) DESC) AS items
+        FROM bucketed
+       GROUP BY week
+       ORDER BY week DESC
+       LIMIT 60
+    `);
+    res.set('Cache-Control', 'public, max-age=600');
+    res.json({ count: r.rowCount, weeks: r.rows });
+  } catch (e: any) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
 // Source-domain breakdown — what platforms are corridor businesses publishing
 // on? wordpress.com, squarespace, custom, etc. Useful editorial signal:
 // "are these mostly self-hosted blogs vs aggregator-pages?"

← 888783b feat(news): /api/news/sources + source-domain bar chart on /  ·  back to Ventura Corridor  ·  feat(news): news-recency badge on /pitches.html name column 4035b3c →