Skip to content

Commit 65c55af

Browse files
committed
feat: add lead-to-approved campaign cycle-time benchmark
1 parent e987013 commit 65c55af

File tree

5 files changed

+270
-0
lines changed

5 files changed

+270
-0
lines changed
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { requireApiUser } from '@/lib/api-auth';
3+
import { getDb } from '@/lib/db';
4+
import { clampDays } from '@/lib/analytics';
5+
import { summarizeCycleTimes, percentImprovement } from '@/lib/benchmarks';
6+
import { maybeSeedExclude } from '@/lib/seed-filter';
7+
8+
export const dynamic = 'force-dynamic';
9+
10+
type CycleTimeRow = {
11+
cycle_hours: number;
12+
};
13+
14+
function parseLaunchDate(raw: string | null | undefined): Date | null {
15+
if (!raw) return null;
16+
const d = new Date(raw);
17+
return Number.isNaN(d.getTime()) ? null : d;
18+
}
19+
20+
function toIsoOrNull(date: Date | null): string | null {
21+
return date ? date.toISOString() : null;
22+
}
23+
24+
function queryCycleTimes(
25+
request: Request,
26+
startIso: string,
27+
endIso: string,
28+
): number[] {
29+
const db = getDb();
30+
const leadSeedFilter = maybeSeedExclude(request, 'leads', 'l.id');
31+
const seqSeedFilter = maybeSeedExclude(request, 'sequences', 's.id');
32+
const rows = db.prepare(
33+
`
34+
SELECT ((julianday(MIN(s.created_at)) - julianday(l.created_at)) * 24.0) AS cycle_hours
35+
FROM leads l
36+
JOIN sequences s ON s.lead_id = l.id
37+
WHERE s.status IN ('approved', 'queued')
38+
AND julianday(l.created_at) >= julianday(?)
39+
AND julianday(l.created_at) < julianday(?)
40+
${leadSeedFilter}
41+
${seqSeedFilter}
42+
GROUP BY l.id, l.created_at
43+
`,
44+
).all(startIso, endIso) as CycleTimeRow[];
45+
46+
return rows
47+
.map((r) => Number(r.cycle_hours))
48+
.filter((h) => Number.isFinite(h) && h >= 0);
49+
}
50+
51+
export async function GET(req: NextRequest) {
52+
const auth = requireApiUser(req as Request);
53+
if (auth) return auth;
54+
55+
const days = clampDays(req.nextUrl.searchParams.get('days'), 30);
56+
const now = new Date();
57+
const launchAt = parseLaunchDate(
58+
req.nextUrl.searchParams.get('launch_at') || process.env.HERMES_BENCHMARK_HERMES_LAUNCH_AT,
59+
);
60+
61+
let beforeStart: Date;
62+
let beforeEnd: Date;
63+
let afterStart: Date;
64+
let afterEnd: Date;
65+
let baselineMode: 'rolling_window' | 'launch_anchored';
66+
67+
if (launchAt) {
68+
baselineMode = 'launch_anchored';
69+
const dMs = days * 24 * 60 * 60 * 1000;
70+
beforeEnd = new Date(launchAt.getTime());
71+
beforeStart = new Date(launchAt.getTime() - dMs);
72+
afterStart = new Date(launchAt.getTime());
73+
const launchPlusWindow = new Date(launchAt.getTime() + dMs);
74+
afterEnd = launchPlusWindow.getTime() < now.getTime() ? launchPlusWindow : now;
75+
} else {
76+
baselineMode = 'rolling_window';
77+
const dMs = days * 24 * 60 * 60 * 1000;
78+
afterEnd = new Date(now.getTime());
79+
afterStart = new Date(now.getTime() - dMs);
80+
beforeEnd = new Date(afterStart.getTime());
81+
beforeStart = new Date(afterStart.getTime() - dMs);
82+
}
83+
84+
const beforeValues = queryCycleTimes(req as Request, beforeStart.toISOString(), beforeEnd.toISOString());
85+
const afterValues = queryCycleTimes(req as Request, afterStart.toISOString(), afterEnd.toISOString());
86+
87+
const beforeStats = summarizeCycleTimes(beforeValues.map((cycleHours) => ({ cycleHours })));
88+
const afterStats = summarizeCycleTimes(afterValues.map((cycleHours) => ({ cycleHours })));
89+
90+
const medianDeltaPct = percentImprovement(beforeStats.medianHours, afterStats.medianHours);
91+
const p90DeltaPct = percentImprovement(beforeStats.p90Hours, afterStats.p90Hours);
92+
93+
return NextResponse.json({
94+
metric: 'lead_to_approved_campaign_cycle_time_hours',
95+
days,
96+
baseline_mode: baselineMode,
97+
window: {
98+
before: { start: beforeStart.toISOString(), end: beforeEnd.toISOString() },
99+
after: { start: afterStart.toISOString(), end: afterEnd.toISOString() },
100+
now: now.toISOString(),
101+
launch_at: toIsoOrNull(launchAt),
102+
},
103+
before: beforeStats,
104+
after: afterStats,
105+
delta: {
106+
median_pct: medianDeltaPct,
107+
p90_pct: p90DeltaPct,
108+
},
109+
inclusion_rules: [
110+
'Lead cohort is based on lead.created_at within each window.',
111+
"Cycle time is MIN(sequence.created_at where sequence.status in ['approved','queued']) - lead.created_at.",
112+
'Only non-negative cycle times are counted.',
113+
'real=true excludes seeded records.',
114+
],
115+
});
116+
}

src/app/page.tsx

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,21 @@ interface OverviewData {
6161

6262
type Role = 'admin' | 'editor' | 'viewer';
6363

64+
interface CycleTimeBenchmarkPayload {
65+
metric: string;
66+
days: number;
67+
baseline_mode: 'rolling_window' | 'launch_anchored';
68+
window: {
69+
before: { start: string; end: string };
70+
after: { start: string; end: string };
71+
now: string;
72+
launch_at: string | null;
73+
};
74+
before: { n: number; medianHours: number | null; p90Hours: number | null };
75+
after: { n: number; medianHours: number | null; p90Hours: number | null };
76+
delta: { median_pct: number | null; p90_pct: number | null };
77+
}
78+
6479
export default function OverviewPage() {
6580
const { realOnly } = useDashboard();
6681
const realParam = realOnly ? '?real=true' : '';
@@ -77,6 +92,11 @@ export default function OverviewPage() {
7792
{ interval: 60_000 },
7893
);
7994

95+
const { data: cycleBenchmark } = useSmartPoll<CycleTimeBenchmarkPayload>(
96+
() => fetch(`/api/benchmarks/cycle-time?days=30${realOnly ? '&real=true' : ''}`).then(r => r.json()),
97+
{ interval: 300_000, key: `cycle-${realOnly}` },
98+
);
99+
80100
useEffect(() => {
81101
fetch('/api/auth/me')
82102
.then((r) => r.json())
@@ -248,6 +268,8 @@ export default function OverviewPage() {
248268
/>
249269
</div>
250270

271+
<CycleTimeBenchmarkPanel data={cycleBenchmark || undefined} />
272+
251273
{/* Charts */}
252274
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
253275
<div className="panel">
@@ -382,6 +404,64 @@ function BudgetBar({ label, used, limit, icon }: { label: string; used: number;
382404
);
383405
}
384406

407+
function formatHours(value: number | null): string {
408+
if (value === null || !Number.isFinite(value)) return '—';
409+
if (value < 1) return `${Math.round(value * 60)}m`;
410+
return `${value.toFixed(1)}h`;
411+
}
412+
413+
function formatDelta(deltaPct: number | null): string {
414+
if (deltaPct === null || !Number.isFinite(deltaPct)) return '—';
415+
const rounded = Math.round(deltaPct * 10) / 10;
416+
const prefix = rounded > 0 ? '+' : '';
417+
return `${prefix}${rounded}%`;
418+
}
419+
420+
function CycleTimeBenchmarkPanel({ data }: { data?: CycleTimeBenchmarkPayload }) {
421+
if (!data) return null;
422+
const improveCls = (data.delta.median_pct ?? -1) >= 0 ? 'text-success' : 'text-warning';
423+
424+
return (
425+
<div className="panel">
426+
<div className="panel-header flex items-center justify-between">
427+
<h3 className="section-title">Lead → Approved Campaign Cycle Time</h3>
428+
<span className="text-[10px] text-muted-foreground font-mono">
429+
{data.baseline_mode === 'launch_anchored' ? 'launch anchored' : `rolling ${data.days}d`}
430+
</span>
431+
</div>
432+
<div className="panel-body space-y-4">
433+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
434+
<div className="card p-4">
435+
<div className="text-xs text-muted-foreground">Before median</div>
436+
<div className="text-lg font-mono font-semibold mt-1">{formatHours(data.before.medianHours)}</div>
437+
</div>
438+
<div className="card p-4">
439+
<div className="text-xs text-muted-foreground">After median</div>
440+
<div className="text-lg font-mono font-semibold mt-1">{formatHours(data.after.medianHours)}</div>
441+
</div>
442+
<div className="card p-4">
443+
<div className="text-xs text-muted-foreground">Before p90</div>
444+
<div className="text-lg font-mono font-semibold mt-1">{formatHours(data.before.p90Hours)}</div>
445+
</div>
446+
<div className="card p-4">
447+
<div className="text-xs text-muted-foreground">After p90</div>
448+
<div className="text-lg font-mono font-semibold mt-1">{formatHours(data.after.p90Hours)}</div>
449+
</div>
450+
</div>
451+
452+
<div className="card p-4 text-sm flex flex-wrap items-center justify-between gap-2">
453+
<div className="text-muted-foreground">
454+
n before <span className="font-mono text-foreground">{data.before.n}</span> · n after <span className="font-mono text-foreground">{data.after.n}</span>
455+
</div>
456+
<div className={`font-mono ${improveCls}`}>
457+
median {formatDelta(data.delta.median_pct)} · p90 {formatDelta(data.delta.p90_pct)}
458+
</div>
459+
</div>
460+
</div>
461+
</div>
462+
);
463+
}
464+
385465
function ActionItemCard({ item, onAction, canEdit }: { item: ActionItem; onAction: () => void; canEdit: boolean }) {
386466
const [acting, setActing] = useState<string | null>(null);
387467

src/lib/benchmarks.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'node:test';
3+
4+
import { percentImprovement, percentile, summarizeCycleTimes } from './benchmarks';
5+
6+
test('percentile returns null for empty and correct values for p50/p90', () => {
7+
assert.equal(percentile([], 50), null);
8+
assert.equal(percentile([10, 20, 30, 40], 50), 20);
9+
assert.equal(percentile([10, 20, 30, 40], 90), 40);
10+
});
11+
12+
test('summarizeCycleTimes computes n, median and p90', () => {
13+
const stats = summarizeCycleTimes([
14+
{ cycleHours: 10 },
15+
{ cycleHours: 2 },
16+
{ cycleHours: 18 },
17+
{ cycleHours: 4 },
18+
{ cycleHours: 36 },
19+
]);
20+
assert.equal(stats.n, 5);
21+
assert.equal(stats.medianHours, 10);
22+
assert.equal(stats.p90Hours, 36);
23+
});
24+
25+
test('percentImprovement returns null on invalid baseline and percent otherwise', () => {
26+
assert.equal(percentImprovement(null, 10), null);
27+
assert.equal(percentImprovement(0, 10), null);
28+
assert.equal(Math.round((percentImprovement(20, 10) || 0) * 100) / 100, 50);
29+
});

src/lib/benchmarks.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
export interface CycleTimeSample {
2+
cycleHours: number;
3+
}
4+
5+
export interface CycleTimeStats {
6+
n: number;
7+
medianHours: number | null;
8+
p90Hours: number | null;
9+
}
10+
11+
export function percentile(values: number[], p: number): number | null {
12+
if (values.length === 0) return null;
13+
const sorted = [...values].sort((a, b) => a - b);
14+
const rank = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
15+
return sorted[rank];
16+
}
17+
18+
export function summarizeCycleTimes(samples: CycleTimeSample[]): CycleTimeStats {
19+
const values = samples
20+
.map((s) => s.cycleHours)
21+
.filter((v) => Number.isFinite(v) && v >= 0);
22+
return {
23+
n: values.length,
24+
medianHours: percentile(values, 50),
25+
p90Hours: percentile(values, 90),
26+
};
27+
}
28+
29+
export function percentImprovement(before: number | null, after: number | null): number | null {
30+
if (before === null || after === null) return null;
31+
if (!Number.isFinite(before) || !Number.isFinite(after) || before <= 0) return null;
32+
return ((before - after) / before) * 100;
33+
}

tests/e2e/auth-and-api.spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,4 +90,16 @@ test.describe('auth and api gate', () => {
9090
expect(Array.isArray(payload.tables)).toBeTruthy();
9191
expect(typeof payload.db_size_mb).toBe('number');
9292
});
93+
94+
test('cycle-time benchmark api returns before/after deltas after login', async ({ request }) => {
95+
const headers = await getAuthHeaders(request);
96+
const res = await request.get('/api/benchmarks/cycle-time?days=30', { headers });
97+
expect(res.status()).toBe(200);
98+
const payload = await res.json();
99+
expect(payload.metric).toBe('lead_to_approved_campaign_cycle_time_hours');
100+
expect(payload).toHaveProperty('before');
101+
expect(payload).toHaveProperty('after');
102+
expect(payload).toHaveProperty('delta');
103+
expect(Array.isArray(payload.inclusion_rules)).toBeTruthy();
104+
});
93105
});

0 commit comments

Comments
 (0)