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