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 (
+
+ 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.
+
+
+
+
+
+
+
+
Campaign
+
Sent
+
People
+
Replied
+
Won
+
Reply rate
+
Close rate
+
+
+
+
+ {campaigns.map((c) => (
+
+ ))}
+
+
+
+
+ {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();
+ });
+});