From 853573fbeebf7f20d2517c6607753562b097ffa7 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 28 Jul 2026 06:40:24 +0000 Subject: [PATCH] feat(leads): show what outreach actually produced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The funnel is rendered at project and campaign level, alongside the per-tick history each campaign already carries — which is where a single run belongs, so it is not duplicated here. It reports measured outcomes, never a projection. Published cold-email benchmarks are someone else's list, someone else's offer and someone else's year; dressing one up as a forecast invites a decision the number cannot support. These are the project's own sends, worth little on day one and more every week. Two denominators, chosen rather than defaulted. Reply rate divides by people contacted, not by sends, because follow-ups mean one person can receive three emails and counting them separately understates every campaign that follows up. Close rate divides by people who replied, because a deal comes out of a conversation and dividing by silence answers a different question. Rates are withheld below twenty sends and say why. One reply out of three is not a thirty-three percent reply rate, and drawing it as one is worse than drawing nothing. A campaign with sends but no replies is told there is no close rate rather than shown a zero, which reads as failure rather than as absence. Outcomes are recordable from the lead row, and only once it has actually been mailed. Nothing here can observe a reply — a send is visible, a reply is not — so until something reads the sending mailbox this is the only way the funnel gets a numerator. Offering the control earlier would invite a reply rate computed over people who were never contacted. Co-Authored-By: Claude Opus 5 (1M context) --- app/(app)/projects/[id]/leads/page.tsx | 13 ++++ components/leads/funnel-panel.tsx | 97 +++++++++++++++++++++++ components/leads/lead-actions.tsx | 48 ++++++++++++ tests/funnel.test.ts | 102 +++++++++++++++++++++++++ 4 files changed, 260 insertions(+) create mode 100644 components/leads/funnel-panel.tsx create mode 100644 tests/funnel.test.ts diff --git a/app/(app)/projects/[id]/leads/page.tsx b/app/(app)/projects/[id]/leads/page.tsx index 4297b244..50d70fcb 100644 --- a/app/(app)/projects/[id]/leads/page.tsx +++ b/app/(app)/projects/[id]/leads/page.tsx @@ -9,6 +9,8 @@ import { CampaignPanel, type CampaignSummary } from "@/components/leads/campaign import { SenderAddress } from "@/components/leads/sender-address"; import { MailboxConnect, type ConnectedMailbox } from "@/components/leads/mailbox-connect"; import { SeedLogins } from "@/components/leads/seed-logins"; +import { FunnelPanel } from "@/components/leads/funnel-panel"; +import { campaignFunnels, projectFunnel } from "@/lib/outreach/funnel"; import { listSeedCredentials, type StoredSeedCredential } from "@/lib/outreach/seedCredentials"; import { RefreshLeads } from "@/components/leads/refresh-leads"; import { loadAddressSettings } from "@/lib/outreach/postalAddress"; @@ -154,6 +156,14 @@ export default async function LeadsPage({ .eq("id", projectId) .maybeSingle(); const orgId = (projectRow?.organization_id as string | null) ?? null; + // Measured outcomes, project-wide and per campaign. Both read the same + // tables the pipeline already writes, so this costs two queries rather than + // any new bookkeeping. + const [funnel, perCampaignFunnel] = await Promise.all([ + projectFunnel(projectId), + campaignFunnels(projectId), + ]); + let seedCredentials: StoredSeedCredential[] = []; if (orgId) seedCredentials = await listSeedCredentials(orgId); @@ -213,6 +223,8 @@ export default async function LeadsPage({ + + )} diff --git a/components/leads/funnel-panel.tsx b/components/leads/funnel-panel.tsx new file mode 100644 index 00000000..51234337 --- /dev/null +++ b/components/leads/funnel-panel.tsx @@ -0,0 +1,97 @@ +import type { CampaignFunnel, FunnelCounts } from "@/lib/outreach/funnel"; + +/** + * What outreach actually produced, across the project and per campaign. + * + * Deliberately not a projection. Published cold-email benchmarks are someone + * else's list, someone else's offer and someone else's year, and dressing one + * up as a forecast invites a decision the number cannot support. These are + * this project's own sends and outcomes; they are worth little on day one and + * more every week. + * + * Per-run numbers are not repeated here — each campaign already carries its + * tick history above, which is where a single run belongs. + */ +function pct(value: number | null): string { + return value === null ? "—" : `${(value * 100).toFixed(1)}%`; +} + +function Row({ label, f }: { label: string; f: FunnelCounts }) { + return ( + + {label} + {f.sent} + {f.contacted} + {f.replied} + {f.won} + {pct(f.replyRate)} + {pct(f.closeRate)} + + ); +} + +export function FunnelPanel({ + project, + campaigns, +}: { + project: FunnelCounts; + campaigns: CampaignFunnel[]; +}) { + if (project.sent === 0) { + return ( +
+

Results

+

+ Nothing has been sent for real yet. Once it has, this shows what came back — replies and + closes measured from your own sends, not from published benchmarks. +

+
+ ); + } + + return ( +
+
+

Results

+

+ Measured from your own sends. Dry runs excluded. +

+
+ +
+ + + + + + + + + + + + + + + {campaigns.map((c) => ( + + ))} + +
CampaignSentPeopleRepliedWonReply rateClose rate
+
+ + {project.rateNote && ( +

+ Rates are held back until there is enough to divide by — {project.rateNote}. One reply out + of three sends is not a 33% reply rate. +

+ )} + +

+ Reply rate is of people contacted; close rate is of people who replied, since a deal comes + out of a conversation. Replies are recorded when you mark a lead — nothing reads your inbox + yet. +

+
+ ); +} diff --git a/components/leads/lead-actions.tsx b/components/leads/lead-actions.tsx index ee8627df..cfa9d02f 100644 --- a/components/leads/lead-actions.tsx +++ b/components/leads/lead-actions.tsx @@ -4,6 +4,7 @@ import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { draftLeadAction, + markLeadOutcomeAction, researchLeadAction, sendLeadAction, suppressLeadAction, @@ -29,6 +30,8 @@ export function LeadActions({ host: string; hasContact: boolean; nextStep: number; + /** Current pipeline status, so outcome controls only appear once contacted. */ + status: string; }) { const router = useRouter(); const [pending, start] = useTransition(); @@ -42,6 +45,20 @@ export function LeadActions({ setError(null); }; + // Outcomes cannot be observed from here — a send is visible, a reply is + // not. Until something reads the sending mailbox, marking one is the only + // way the funnel gets a numerator, so the control sits on the lead itself + // rather than behind a menu. + const markOutcome = (outcome: "replied" | "won" | "lost") => + start(async () => { + clear(); + const res = await markLeadOutcomeAction({ projectId, host, outcome }); + if (res.ok) { + setNote(res.note); + router.refresh(); + } else setError(res.error); + }); + const research = () => start(async () => { clear(); @@ -112,6 +129,37 @@ export function LeadActions({ + {/* Only after a lead has actually been mailed — there is no outcome to + record before that, and offering one would invite a reply rate + computed over people who were never contacted. */} + {["contacted", "replied", "won", "lost"].includes(status) && ( +
+ Outcome: + + + +
+ )} + {note &&

{note}

} {error &&

{error}

} diff --git a/tests/funnel.test.ts b/tests/funnel.test.ts new file mode 100644 index 00000000..c063c73a --- /dev/null +++ b/tests/funnel.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// The funnel reads two tables; the arithmetic on top of them is what matters +// and is what these cover. A fake client keeps the maths under test without +// standing up a database. +const rows: Record = { sends: [], prospects: [], campaigns: [] }; +let sendCount = 0; + +vi.mock("@/lib/supabase/service", () => ({ + serviceClient: () => ({ + from(table: string) { + const chain = { + select: (_cols: string, opts?: { head?: boolean }) => + opts?.head ? { ...chain, count: sendCount } : chain, + eq: () => chain, + in: () => chain, + then: undefined, + } as Record; + // Terminal await resolves to the rows for this table. + const key = + table === "outreach_sends" ? "sends" : table === "outreach_prospects" ? "prospects" : "campaigns"; + Object.assign(chain, { + then: (resolve: (v: unknown) => void) => + resolve({ data: rows[key], count: table === "outreach_sends" ? sendCount : null }), + }); + return chain; + }, + }), +})); + +const { projectFunnel } = await import("@/lib/outreach/funnel"); + +function prospects(spec: Record) { + const out: { status: string }[] = []; + for (const [status, n] of Object.entries(spec)) { + for (let i = 0; i < n; i++) out.push({ status }); + } + return out; +} + +beforeEach(() => { + rows.sends = []; + rows.prospects = []; + rows.campaigns = []; + sendCount = 0; +}); + +describe("projectFunnel", () => { + it("withholds rates until the sample can carry one", async () => { + sendCount = 3; + rows.prospects = prospects({ contacted: 2, replied: 1 }); + const f = await projectFunnel("p1"); + // One reply in three sends is not a 33% reply rate. + expect(f.replyRate).toBeNull(); + expect(f.closeRate).toBeNull(); + expect(f.rateNote).toMatch(/17 more sends/); + }); + + it("computes reply rate over people contacted, not sends", async () => { + sendCount = 60; // follow-ups mean sends exceed people + rows.prospects = prospects({ contacted: 36, replied: 4 }); + const f = await projectFunnel("p1"); + expect(f.contacted).toBe(40); + // 4 replies / 40 people, not 4 / 60 sends. + expect(f.replyRate).toBeCloseTo(0.1); + }); + + it("counts won and lost as having replied", async () => { + sendCount = 40; + rows.prospects = prospects({ contacted: 20, replied: 5, won: 3, lost: 2 }); + const f = await projectFunnel("p1"); + // A deal or a rejection both required a conversation first. + expect(f.replied).toBe(10); + expect(f.won).toBe(3); + expect(f.lost).toBe(2); + }); + + it("computes close rate over replies, not over everyone contacted", async () => { + sendCount = 40; + rows.prospects = prospects({ contacted: 30, replied: 6, won: 4 }); + const f = await projectFunnel("p1"); + expect(f.replied).toBe(10); + // 4 won / 10 replied — dividing by all 40 contacted would flatter nothing + // and would answer a different question. + expect(f.closeRate).toBeCloseTo(0.4); + }); + + it("says there is no close rate rather than showing zero", async () => { + sendCount = 40; + rows.prospects = prospects({ contacted: 40 }); + const f = await projectFunnel("p1"); + expect(f.replyRate).toBe(0); + expect(f.closeRate).toBeNull(); + expect(f.rateNote).toMatch(/no replies yet/); + }); + + it("reports zeroes cleanly with no data at all", async () => { + const f = await projectFunnel("p1"); + expect(f).toMatchObject({ sent: 0, contacted: 0, replied: 0, won: 0 }); + expect(f.replyRate).toBeNull(); + }); +});