[object Object]

← back to Ventura Corridor

iter 60: recent activity feed on /today.html — /api/activity?limit=&event_type= returns last N events from pitch_event_log JOINed with pitch + business; aggregate stats: last_24h, last_7d, pitches_touched_7d, sent_7d, replied_7d; /today.html gains an Activity card directly under the velocity chart with 30 most-recent events (icon + business name + event type + old→new arrow + time-ago) auto-refreshing every 60s; click any row to open the pitch; surfaces the value of iter-51's audit trail at a glance

6dbbf08be2f1ccf8926a01e8aa9f59bd2377141e · 2026-05-06 13:33:36 -0700 · SteveStudio2

Files touched

Diff

commit 6dbbf08be2f1ccf8926a01e8aa9f59bd2377141e
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Wed May 6 13:33:36 2026 -0700

    iter 60: recent activity feed on /today.html — /api/activity?limit=&event_type= returns last N events from pitch_event_log JOINed with pitch + business; aggregate stats: last_24h, last_7d, pitches_touched_7d, sent_7d, replied_7d; /today.html gains an Activity card directly under the velocity chart with 30 most-recent events (icon + business name + event type + old→new arrow + time-ago) auto-refreshing every 60s; click any row to open the pitch; surfaces the value of iter-51's audit trail at a glance
---
 public/today.html   | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/server/index.ts | 39 ++++++++++++++++++++++++++++++
 2 files changed, 107 insertions(+)

diff --git a/public/today.html b/public/today.html
index 850e066..aa31855 100644
--- a/public/today.html
+++ b/public/today.html
@@ -143,6 +143,17 @@
   <div id="trend-legend" style="display:flex;gap:16px;flex-wrap:wrap;margin-top:12px;font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-mute)"></div>
 </section>
 
+<section style="padding:24px 32px;border-bottom:1px solid var(--rule);background:var(--noir-rise)">
+  <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:16px;flex-wrap:wrap;gap:12px">
+    <h3 style="font-family:var(--serif);font-style:italic;font-weight:400;font-size:22px;color:var(--metal);margin:0">Recent activity</h3>
+    <div id="activity-stats" style="font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-mute)">—</div>
+  </div>
+  <div id="activity-feed" style="display:flex;flex-direction:column;gap:6px"></div>
+  <div style="margin-top:12px;font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-mute)">
+    auto-refresh every 60s · click any row to open the pitch
+  </div>
+</section>
+
 <div class="stack">
 
 <section class="card">
@@ -384,6 +395,63 @@ loadInMail();
   document.getElementById('trend-stats').innerHTML =
     `last 7 days: <b style="color:var(--metal-glow);font-family:var(--mono)">+${delta7}</b> sent · <b style="color:var(--metal);font-family:var(--mono)">${dates.length}</b> days captured`;
 })();
+
+// ─── Recent activity feed (iter 60) ─────────────────────────────────
+const EVENT_ICON = {
+  status_change: '✎', channel_change: '🔀',
+  sent: '📤', replied: '📥', closed: '🔒',
+  reply_text: '💬', followup_scheduled: '⏰',
+  created: '✨'
+};
+const EVENT_COLOR = {
+  sent: 'var(--metal)', replied: 'var(--metal-glow)',
+  closed: 'var(--green)', won: 'var(--green)',
+  status_change: 'var(--ink)', channel_change: 'var(--ink)',
+  reply_text: 'var(--metal-glow)', followup_scheduled: 'var(--metal)',
+  created: 'var(--ink-mute)'
+};
+function timeAgo(iso) {
+  const d = new Date(iso);
+  const s = Math.round((Date.now() - d.getTime()) / 1000);
+  if (s < 60) return s + 's ago';
+  if (s < 3600) return Math.round(s / 60) + 'm ago';
+  if (s < 86400) return Math.round(s / 3600) + 'h ago';
+  if (s < 604800) return Math.round(s / 86400) + 'd ago';
+  return d.toLocaleDateString();
+}
+function escHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
+async function loadActivity() {
+  try {
+    const data = await fetch('/api/activity?limit=30').then(r => r.json());
+    const stats = data.stats || {};
+    document.getElementById('activity-stats').innerHTML =
+      `<b style="color:var(--metal-glow);font-family:var(--mono)">${stats.last_24h || 0}</b> events · 24h ` +
+      `<b style="color:var(--metal);font-family:var(--mono);margin-left:14px">${stats.sent_7d || 0}</b> sent · 7d ` +
+      `<b style="color:var(--metal-glow);font-family:var(--mono);margin-left:14px">${stats.replied_7d || 0}</b> replied · 7d`;
+    const events = data.events || [];
+    const feed = document.getElementById('activity-feed');
+    if (!events.length) {
+      feed.innerHTML = `<div style="padding:24px;text-align:center;color:var(--ink-mute);font-style:italic">No activity yet — first walk-in or DM will show here.</div>`;
+      return;
+    }
+    feed.innerHTML = events.map(ev => {
+      const icon = EVENT_ICON[ev.event_type] || '·';
+      const color = EVENT_COLOR[ev.event_type] || 'var(--ink-mute)';
+      const arrow = ev.old_value ? `${escHtml(ev.old_value)} → ${escHtml(ev.new_value || '')}` : escHtml(ev.new_value || '');
+      return `
+        <a href="/pitches.html?id=${ev.pitch_id}" target="_blank" style="display:grid;grid-template-columns:auto 1fr auto auto;gap:14px;align-items:center;padding:8px 12px;background:var(--noir);border:1px solid var(--rule);text-decoration:none;color:var(--ink);font-size:12px">
+          <span style="color:${color};font-size:16px;width:22px;text-align:center">${icon}</span>
+          <span><b style="font-family:var(--serif);font-size:14px;color:var(--metal-glow)">${escHtml(ev.biz_name)}</b> <span style="color:var(--ink-mute);margin-left:6px">${escHtml(ev.pitch_type || '')}</span></span>
+          <span style="color:${color};font-family:var(--mono);font-size:10px;letter-spacing:.15em;text-transform:uppercase">${escHtml(ev.event_type)} ${arrow ? '· ' + arrow : ''}</span>
+          <span style="color:var(--ink-mute);font-family:var(--mono);font-size:10px">${timeAgo(ev.occurred_at)}</span>
+        </a>`;
+    }).join('');
+  } catch (e) {
+    document.getElementById('activity-feed').innerHTML = `<div style="color:var(--ink-mute);padding:14px">activity feed offline</div>`;
+  }
+}
+loadActivity();
+setInterval(loadActivity, 60_000);
 </script>
 </body>
 </html>
diff --git a/src/server/index.ts b/src/server/index.ts
index 9f14ac4..72546b9 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -67,6 +67,7 @@ const ADMIN_PATHS = [
   /^\/api\/responses(\.csv|\/.*)?$/i,
   /^\/api\/followups\/?$/i,
   /^\/api\/snapshots\/?$/i,
+  /^\/api\/activity\/?$/i,
   /^\/postcards(\.html)?\/?$/i,
   /^\/buildings(\.html)?\/?$/i,
   /^\/api\/buildings(\/.*)?$/i,
@@ -898,6 +899,44 @@ app.patch('/api/pitches/:id', async (req, res) => {
   }
 });
 
+// ─── Recent activity feed: stream of pitch_event_log events with business name ──
+// Powers the "Recent activity" card on /today.html so Steve sees the corridor breathe.
+app.get('/api/activity', async (req, res) => {
+  try {
+    const limit = Math.min(parseInt(String(req.query.limit ?? '30'), 10) || 30, 200);
+    const eventType = String(req.query.event_type || '').trim();
+    const where: string[] = [];
+    const params: any[] = [];
+    if (eventType) { params.push(eventType); where.push(`pel.event_type = $${params.length}`); }
+    params.push(limit);
+    const r = await query(
+      `SELECT pel.id AS event_id, pel.pitch_id, pel.event_type, pel.old_value, pel.new_value, pel.occurred_at,
+              b.name AS biz_name, b.address AS biz_address, b.city,
+              p.pitch_type, p.priority, p.status, p.outreach_channel, p.dw_proximity
+       FROM pitch_event_log pel
+       JOIN pitches p   ON p.id = pel.pitch_id
+       JOIN businesses b ON b.id = p.business_id
+       ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
+       ORDER BY pel.occurred_at DESC, pel.id DESC
+       LIMIT $${params.length}`,
+      params
+    );
+    // Aggregate counts for the strip
+    const stats = await query(`
+      SELECT
+        count(*) FILTER (WHERE occurred_at >= NOW() - INTERVAL '24 hours')                                AS last_24h,
+        count(*) FILTER (WHERE occurred_at >= NOW() - INTERVAL '7 days')                                  AS last_7d,
+        count(DISTINCT pitch_id) FILTER (WHERE occurred_at >= NOW() - INTERVAL '7 days')                  AS pitches_touched_7d,
+        count(*) FILTER (WHERE event_type = 'sent'    AND occurred_at >= NOW() - INTERVAL '7 days')       AS sent_7d,
+        count(*) FILTER (WHERE event_type = 'replied' AND occurred_at >= NOW() - INTERVAL '7 days')       AS replied_7d
+      FROM pitch_event_log
+    `);
+    res.json({ count: r.rowCount, events: r.rows, stats: stats.rows[0] });
+  } catch (e: any) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
 // ─── Bulk pitch action: apply one update to many pitch IDs at once ───
 // Body: { ids: number[], fields: { status?, outreach_channel?, priority?, notes? } }
 // Reuses the same allowlist as PATCH /api/pitches/:id; lifecycle stamps fire too via the trigger.

← 82a93bd iter 59: pitch 1-pager — GET /api/pitches/:id/sheet.md rende  ·  back to Ventura Corridor  ·  iter 61: building prioritization 'walk here next' — /api/bui 40a7c21 →