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(); + }); +});