← 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
M public/responses.htmlM src/server/index.ts
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 →