[object Object]

← 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

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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 →