[object Object]

← back to Ventura Corridor

iter 68: per-vertical conversion stats — /api/responses/by-vertical aggregates pitch_type → (pitches, sent, replied, won, lost, reply_rate_pct, win_rate_pct, won_value_usd, avg_won_value); /responses.html grows a 'Conversion by vertical' table directly under the channel funnel showing 9 columns per pitch_type, reply rate >=10% colored metal-glow, win rate >=5% colored green; surfaces strategy data once Steve starts getting replies — currently 13 verticals with real-estate (1603 pitches), medical-clinic (1191), beauty-salon (919), law-office (851), hospitality (555) leading the cohort sizes

43adaa8935d0b3d3267a743878964a47a22ebefb · 2026-05-06 15:25:38 -0700 · SteveStudio2

Files touched

Diff

commit 43adaa8935d0b3d3267a743878964a47a22ebefb
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Wed May 6 15:25:38 2026 -0700

    iter 68: per-vertical conversion stats — /api/responses/by-vertical aggregates pitch_type → (pitches, sent, replied, won, lost, reply_rate_pct, win_rate_pct, won_value_usd, avg_won_value); /responses.html grows a 'Conversion by vertical' table directly under the channel funnel showing 9 columns per pitch_type, reply rate >=10% colored metal-glow, win rate >=5% colored green; surfaces strategy data once Steve starts getting replies — currently 13 verticals with real-estate (1603 pitches), medical-clinic (1191), beauty-salon (919), law-office (851), hospitality (555) leading the cohort sizes
---
 public/responses.html | 37 +++++++++++++++++++++++++++++++++++++
 src/server/index.ts   | 28 ++++++++++++++++++++++++++++
 2 files changed, 65 insertions(+)

diff --git a/public/responses.html b/public/responses.html
index bc274f3..4e67af2 100644
--- a/public/responses.html
+++ b/public/responses.html
@@ -200,6 +200,17 @@
     </tr></thead>
     <tbody id="channel-funnel"></tbody>
   </table>
+
+  <h3 style="font-family:var(--serif);font-style:italic;font-weight:400;font-size:18px;color:var(--metal);margin:32px 0 12px">Conversion by vertical · which pitch type wins most</h3>
+  <table class="channel-funnel">
+    <thead><tr>
+      <th>Vertical</th>
+      <th class="num">Pitches</th><th class="num">Sent</th><th class="num">Replied</th>
+      <th class="num">Reply rate</th><th class="num">Won</th><th class="num">Win rate</th>
+      <th class="num">Won $</th><th class="num">Avg deal</th>
+    </tr></thead>
+    <tbody id="vertical-conv"></tbody>
+  </table>
 </section>
 
 <div class="filter-bar">
@@ -471,9 +482,35 @@ document.querySelectorAll('.filter-bar .pill').forEach(p => {
   });
 });
 
+async function loadVerticalConv() {
+  try {
+    const data = await fetch('/api/responses/by-vertical').then(r => r.json());
+    const rows = data.by_vertical || [];
+    const tbody = document.getElementById('vertical-conv');
+    if (!rows.length) {
+      tbody.innerHTML = `<tr><td colspan="9" style="padding:20px;text-align:center;color:var(--ink-mute);font-style:italic">No pitch data yet.</td></tr>`;
+      return;
+    }
+    tbody.innerHTML = rows.map(r => `
+      <tr>
+        <td class="channel">${escapeHtml(r.pitch_type)}</td>
+        <td class="num">${r.pitches}</td>
+        <td class="num">${r.sent}</td>
+        <td class="num">${r.replied}</td>
+        <td class="num" style="${Number(r.reply_rate_pct) >= 10 ? 'color:var(--metal-glow)' : ''}">${r.reply_rate_pct ?? '—'}${r.reply_rate_pct ? '%' : ''}</td>
+        <td class="num">${r.won}</td>
+        <td class="num" style="${Number(r.win_rate_pct) >= 5 ? 'color:var(--green)' : ''}">${r.win_rate_pct ?? '—'}${r.win_rate_pct ? '%' : ''}</td>
+        <td class="num">${r.won_value_usd ? '$' + Number(r.won_value_usd).toLocaleString() : '—'}</td>
+        <td class="num">${r.avg_won_value ? '$' + Number(r.avg_won_value).toLocaleString() : '—'}</td>
+      </tr>
+    `).join('');
+  } catch (e) {}
+}
+
 loadFunnel();
 loadFollowups();
 loadResponses();
+loadVerticalConv();
 
 // Deep-link via ?id= URL param: open edit modal for that pitch
 (async () => {
diff --git a/src/server/index.ts b/src/server/index.ts
index 1ab28c9..b6b4b9b 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -1704,6 +1704,34 @@ app.get('/api/responses.csv', async (_req, res) => {
   }
 });
 
+// ─── Per-vertical conversion: which pitch_type replies/wins most ──────
+app.get('/api/responses/by-vertical', async (_req, res) => {
+  try {
+    const r = await query(`
+      SELECT
+        pitch_type,
+        count(*)                                            AS pitches,
+        count(*) FILTER (WHERE sent_at IS NOT NULL)         AS sent,
+        count(*) FILTER (WHERE replied_at IS NOT NULL)      AS replied,
+        count(*) FILTER (WHERE status = 'won')              AS won,
+        count(*) FILTER (WHERE status = 'lost')             AS lost,
+        ROUND(100.0 * count(*) FILTER (WHERE replied_at IS NOT NULL)
+              / NULLIF(count(*) FILTER (WHERE sent_at IS NOT NULL), 0), 1)        AS reply_rate_pct,
+        ROUND(100.0 * count(*) FILTER (WHERE status = 'won')
+              / NULLIF(count(*) FILTER (WHERE sent_at IS NOT NULL), 0), 1)        AS win_rate_pct,
+        COALESCE(SUM(won_value_usd) FILTER (WHERE status = 'won'), 0)             AS won_value_usd,
+        ROUND(AVG(won_value_usd) FILTER (WHERE status = 'won')::numeric, 2)       AS avg_won_value
+      FROM pitches
+      WHERE pitch_type IS NOT NULL
+      GROUP BY pitch_type
+      ORDER BY count(*) FILTER (WHERE sent_at IS NOT NULL) DESC, pitches DESC
+    `);
+    res.json({ count: r.rowCount, by_vertical: r.rows });
+  } catch (e: any) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
 // ─── Cycle-time analytics: how long sent → reply / sent → close ─────
 app.get('/api/responses/cycle-time', async (_req, res) => {
   try {

← eb0c998 iter 67: 'Walk here next' priority strip on /today.html — to  ·  back to Ventura Corridor  ·  iter 69: BTRC data freshness pill on /today.html — /api/data 140c1a6 →