Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions app/(app)/projects/[id]/leads/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -213,6 +223,8 @@ export default async function LeadsPage({

<CampaignPanel projectId={projectId} campaigns={campaigns} canSendLive={canSendLive} />

<FunnelPanel project={funnel} campaigns={perCampaignFunnel} />

<SeedLogins
projectId={projectId}
waitingHosts={waitingHosts}
Expand Down Expand Up @@ -296,6 +308,7 @@ export default async function LeadsPage({
host={p.target_key}
hasContact={Boolean(p.contact_email)}
nextStep={Math.min((p.last_step ?? 0) + 1, 3)}
status={p.status}
/>
)}
</li>
Expand Down
97 changes: 97 additions & 0 deletions components/leads/funnel-panel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<tr className="border-t border-[var(--color-border)]">
<td className="py-2 pr-3 font-medium">{label}</td>
<td className="py-2 pr-3 text-right font-mono">{f.sent}</td>
<td className="py-2 pr-3 text-right font-mono">{f.contacted}</td>
<td className="py-2 pr-3 text-right font-mono">{f.replied}</td>
<td className="py-2 pr-3 text-right font-mono">{f.won}</td>
<td className="py-2 pr-3 text-right font-mono">{pct(f.replyRate)}</td>
<td className="py-2 text-right font-mono">{pct(f.closeRate)}</td>
</tr>
);
}

export function FunnelPanel({
project,
campaigns,
}: {
project: FunnelCounts;
campaigns: CampaignFunnel[];
}) {
if (project.sent === 0) {
return (
<section className="card p-4">
<h2 className="text-lg font-semibold">Results</h2>
<p className="mt-1 text-sm text-[var(--color-muted)]">
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.
</p>
</section>
);
}

return (
<section className="card p-4">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h2 className="text-lg font-semibold">Results</h2>
<p className="text-xs text-[var(--color-muted)]">
Measured from your own sends. Dry runs excluded.
</p>
</div>

<div className="mt-3 overflow-x-auto">
<table className="w-full min-w-[34rem] text-sm">
<thead>
<tr className="text-xs text-[var(--color-muted)]">
<th className="pb-1 text-left font-medium">Campaign</th>
<th className="pb-1 pr-3 text-right font-medium">Sent</th>
<th className="pb-1 pr-3 text-right font-medium">People</th>
<th className="pb-1 pr-3 text-right font-medium">Replied</th>
<th className="pb-1 pr-3 text-right font-medium">Won</th>
<th className="pb-1 pr-3 text-right font-medium">Reply rate</th>
<th className="pb-1 text-right font-medium">Close rate</th>
</tr>
</thead>
<tbody>
<Row label="All campaigns" f={project} />
{campaigns.map((c) => (
<Row key={c.campaign} label={c.campaign} f={c} />
))}
</tbody>
</table>
</div>

{project.rateNote && (
<p className="mt-3 text-xs text-[var(--color-muted)]">
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.
</p>
)}

<p className="mt-2 text-xs text-[var(--color-muted)]">
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.
</p>
</section>
);
}
48 changes: 48 additions & 0 deletions components/leads/lead-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
draftLeadAction,
markLeadOutcomeAction,
researchLeadAction,
sendLeadAction,
suppressLeadAction,
Expand All @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -112,6 +129,37 @@ export function LeadActions({
</button>
</div>

{/* 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) && (
<div className="flex flex-wrap items-center justify-end gap-2">
<span className="text-xs text-[var(--color-muted)]">Outcome:</span>
<button
onClick={() => markOutcome("replied")}
disabled={pending || status !== "contacted"}
className="btn text-xs"
title={status === "contacted" ? "" : "Already recorded"}
>
Replied
</button>
<button
onClick={() => markOutcome("won")}
disabled={pending || status === "won"}
className="btn text-xs"
>
Won
</button>
<button
onClick={() => markOutcome("lost")}
disabled={pending || status === "lost"}
className="btn text-xs"
>
Lost
</button>
</div>
)}

{note && <p className="text-xs text-[var(--color-muted)]">{note}</p>}
{error && <p className="max-w-md text-right text-xs text-[var(--color-danger,#f87171)]">{error}</p>}

Expand Down
102 changes: 102 additions & 0 deletions tests/funnel.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown[]> = { 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<string, unknown>;
// 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<string, number>) {
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();
});
});
Loading