← 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
A public/news/archive.htmlM src/server/index.ts
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 →