← back to Ventura Corridor
iter 65: /api/buildings.csv export + activity-feed event-type filter chips — /api/buildings.csv?min_tenants=&max_miles= returns full v_building_roster as RFC-safe CSV with bldg_address/city/zip/lat/lng/dw_miles/tenants/pitched/walked/replied/won/skipped/draft/unpitched + computed priority_score + dominant_pitch_type, Content-Disposition attachment with dated filename; /buildings.html stats strip gets ⬇ Buildings CSV button alongside the merge-sweep buttons; /today.html activity card gains 9 filter chips (All / Sent / Replied / Closed / Status / Channel / Reply / Follow-up / Created) — clicking a chip refilters via /api/activity?event_type=, active state shown with metal-tone outline + tinted bg; ADMIN_PATHS regex tightened to match /api/buildings.csv
10506e02ffde704eed8e60953f36ed281a57b442 · 2026-05-06 14:57:47 -0700 · SteveStudio2
Files touched
M public/buildings.htmlM public/today.htmlM src/server/index.ts
Diff
commit 10506e02ffde704eed8e60953f36ed281a57b442
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date: Wed May 6 14:57:47 2026 -0700
iter 65: /api/buildings.csv export + activity-feed event-type filter chips — /api/buildings.csv?min_tenants=&max_miles= returns full v_building_roster as RFC-safe CSV with bldg_address/city/zip/lat/lng/dw_miles/tenants/pitched/walked/replied/won/skipped/draft/unpitched + computed priority_score + dominant_pitch_type, Content-Disposition attachment with dated filename; /buildings.html stats strip gets ⬇ Buildings CSV button alongside the merge-sweep buttons; /today.html activity card gains 9 filter chips (All / Sent / Replied / Closed / Status / Channel / Reply / Follow-up / Created) — clicking a chip refilters via /api/activity?event_type=, active state shown with metal-tone outline + tinted bg; ADMIN_PATHS regex tightened to match /api/buildings.csv
---
public/buildings.html | 1 +
public/today.html | 27 ++++++++++++++++++++++++++-
src/server/index.ts | 42 +++++++++++++++++++++++++++++++++++++++++-
3 files changed, 68 insertions(+), 2 deletions(-)
diff --git a/public/buildings.html b/public/buildings.html
index 7eb3591..11a1917 100644
--- a/public/buildings.html
+++ b/public/buildings.html
@@ -172,6 +172,7 @@
<div class="stat" style="border-left:1px solid var(--rule);padding-left:24px">
<button onclick="bulkMerge(1.0, null, true)" style="background:transparent;border:1px solid var(--metal);color:var(--metal);padding:7px 14px;font-size:9px;letter-spacing:.18em;text-transform:uppercase;cursor:pointer">Preview · merge all 1.00</button>
<button onclick="bulkMerge(1.0, null, false)" style="background:transparent;border:1px solid var(--metal-glow);color:var(--metal-glow);padding:7px 14px;font-size:9px;letter-spacing:.18em;text-transform:uppercase;cursor:pointer;margin-left:6px">⚡ Sweep merge 1.00</button>
+ <a href="/api/buildings.csv?min_tenants=2" download style="background:transparent;border:1px solid var(--metal);color:var(--metal);padding:7px 14px;font-size:9px;letter-spacing:.18em;text-transform:uppercase;text-decoration:none;margin-left:6px">⬇ Buildings CSV</a>
</div>
</div>
diff --git a/public/today.html b/public/today.html
index 1cc496d..119fcc0 100644
--- a/public/today.html
+++ b/public/today.html
@@ -149,6 +149,7 @@
<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-chips" style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px"></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
@@ -421,9 +422,33 @@ function timeAgo(iso) {
return d.toLocaleDateString();
}
function escHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
+let activityFilter = '';
+function renderActivityChips() {
+ const types = [
+ { id: '', label: 'All', color: 'var(--metal)' },
+ { id: 'sent', label: '📤 Sent', color: 'var(--metal)' },
+ { id: 'replied', label: '📥 Replied', color: 'var(--metal-glow)' },
+ { id: 'closed', label: '🔒 Closed', color: 'var(--green)' },
+ { id: 'status_change', label: '✎ Status', color: 'var(--ink)' },
+ { id: 'channel_change', label: '🔀 Channel', color: 'var(--ink)' },
+ { id: 'reply_text', label: '💬 Reply', color: 'var(--metal-glow)' },
+ { id: 'followup_scheduled', label: '⏰ Follow-up', color: 'var(--metal)' },
+ { id: 'created', label: '✨ Created', color: 'var(--ink-mute)' },
+ ];
+ document.getElementById('activity-chips').innerHTML = types.map(t => {
+ const active = activityFilter === t.id;
+ return `<button data-type="${t.id}" style="background:${active ? 'rgba(184,153,104,0.1)' : 'transparent'};border:1px solid ${active ? t.color : 'var(--rule)'};color:${active ? t.color : 'var(--ink-mute)'};padding:4px 10px;font-size:9px;letter-spacing:.2em;text-transform:uppercase;cursor:pointer;font-family:var(--sans)">${t.label}</button>`;
+ }).join('');
+ document.getElementById('activity-chips').querySelectorAll('button').forEach(b => {
+ b.addEventListener('click', () => { activityFilter = b.dataset.type; renderActivityChips(); loadActivity(); });
+ });
+}
+renderActivityChips();
+
async function loadActivity() {
try {
- const data = await fetch('/api/activity?limit=30').then(r => r.json());
+ const url = activityFilter ? `/api/activity?limit=30&event_type=${activityFilter}` : '/api/activity?limit=30';
+ const data = await fetch(url).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 ` +
diff --git a/src/server/index.ts b/src/server/index.ts
index b758789..1ab28c9 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -72,7 +72,7 @@ const ADMIN_PATHS = [
/^\/api\/crawl(\/.*)?$/i,
/^\/postcards(\.html)?\/?$/i,
/^\/buildings(\.html)?\/?$/i,
- /^\/api\/buildings(\/.*)?$/i,
+ /^\/api\/buildings(\.csv|\/.*)?$/i,
/^\/pitch\/\d+\/?$/i,
];
app.use((req, res, next) => {
@@ -1318,6 +1318,46 @@ app.get('/api/buildings/priority', async (req, res) => {
}
});
+// CSV export of every multi-tenant building roster — for accountant/CRM/analyst hand-off
+app.get('/api/buildings.csv', async (req, res) => {
+ try {
+ const minTenants = parseInt(String(req.query.min_tenants ?? '2'), 10) || 2;
+ const maxMiles = parseFloat(String(req.query.max_miles ?? '999'));
+ const r = await query(
+ `SELECT bldg_address, city, zip, lat, lng, dw_miles,
+ tenants_total, pitched, walked, replied, won, skipped, draft, unpitched,
+ dominant_pitch_type
+ FROM v_building_roster
+ WHERE tenants_total >= $1 AND (dw_miles IS NULL OR dw_miles <= $2)
+ ORDER BY dw_miles ASC NULLS LAST, tenants_total DESC`,
+ [minTenants, maxMiles]
+ );
+ const headers = [
+ 'bldg_address','city','zip','lat','lng','dw_miles',
+ 'tenants_total','pitched','walked','replied','won','skipped','draft','unpitched',
+ 'priority_score','dominant_pitch_type'
+ ];
+ const rows = r.rows.map((row: any) => {
+ const score = row.dw_miles && row.unpitched
+ ? Math.round((Number(row.unpitched) / Math.max(Number(row.dw_miles), 0.05)) * 10) / 10
+ : '';
+ const data = { ...row, priority_score: score };
+ return headers.map(h => {
+ const v = data[h];
+ if (v === null || v === undefined) return '';
+ const s = String(v);
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
+ }).join(',');
+ });
+ const csv = [headers.join(','), ...rows].join('\n');
+ res.setHeader('Content-Type', 'text/csv; charset=utf-8');
+ res.setHeader('Content-Disposition', `attachment; filename="ventura-corridor-buildings-${new Date().toISOString().slice(0,10)}.csv"`);
+ res.send(csv);
+ } catch (e: any) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
app.get('/api/buildings/dupe-stats', async (_req, res) => {
try {
const r = await query(`
← e654bdf iter 64: building pitch-type heatmap — /api/buildings/:bldg/
·
back to Ventura Corridor
·
iter 66: /pitches.html multi-select checkboxes for surgical 4557347 →