///
import {
type Cell,
cell,
Default,
handler,
lift,
recipe,
str,
} from "commontools";
interface FunnelStageInput {
id?: string;
label?: string;
count?: number;
}
interface FunnelAnalyticsArgs {
stages: Default;
}
interface FunnelStage extends FunnelStageInput {
id: string;
label: string;
count: number;
}
interface StageMetric extends FunnelStage {
dropOffRate: number;
conversionRate: number;
dropOffPercent: string;
conversionPercent: string;
}
interface FunnelDropOffDetail {
fromId: string;
toId: string;
fromStage: string;
toStage: string;
lost: number;
dropOffRate: number;
dropOffPercent: string;
}
interface StageUpdateEvent {
stageId?: string;
delta?: number;
value?: number;
}
interface StageUpdateEntry {
stageId: string;
label: string;
count: number;
mode: "delta" | "value";
}
const defaultStageSeeds: FunnelStageInput[] = [
{ id: "awareness", label: "Awareness", count: 1200 },
{ id: "interest", label: "Interest", count: 720 },
{ id: "evaluation", label: "Evaluation", count: 320 },
{ id: "purchase", label: "Purchase", count: 96 },
];
const normalizeSlug = (value: string): string =>
value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
const sanitizeId = (value: unknown, fallback: string): string => {
if (typeof value === "string" && value.trim().length > 0) {
const normalized = normalizeSlug(value);
if (normalized.length > 0) return normalized;
}
const fallbackNormalized = normalizeSlug(fallback);
return fallbackNormalized.length > 0 ? fallbackNormalized : "stage";
};
const sanitizeLabel = (value: unknown, fallback: string): string => {
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.length > 0) return trimmed;
}
return fallback;
};
const sanitizeCount = (value: unknown, fallback: number): number => {
const base = Number.isFinite(fallback) ? Math.round(fallback) : 0;
if (typeof value !== "number" || !Number.isFinite(value)) {
return base < 0 ? 0 : base;
}
const rounded = Math.round(value);
return rounded < 0 ? 0 : rounded;
};
const fallbackStageAt = (index: number): FunnelStage => {
const seed = defaultStageSeeds[index];
const fallbackLabel = sanitizeLabel(seed?.label, `Stage ${index + 1}`);
const idSource = seed?.id ?? fallbackLabel;
const id = sanitizeId(idSource, `stage-${index + 1}`);
const count = sanitizeCount(seed?.count, seed?.count ?? 0);
return { id, label: fallbackLabel, count };
};
const cloneDefaultStages = (): FunnelStage[] =>
defaultStageSeeds.map((_, index) => ({ ...fallbackStageAt(index) }));
const sanitizeStage = (
value: FunnelStageInput | undefined,
index: number,
): FunnelStage => {
const fallback = fallbackStageAt(index);
const id = sanitizeId(value?.id ?? value?.label, fallback.id);
const label = sanitizeLabel(value?.label, fallback.label);
const count = sanitizeCount(value?.count, fallback.count);
return { id, label, count };
};
const sanitizeStages = (value: unknown): FunnelStage[] => {
if (!Array.isArray(value)) return cloneDefaultStages();
const seen = new Set();
const sanitized: FunnelStage[] = [];
for (let index = 0; index < value.length; index++) {
const entry = sanitizeStage(
value[index] as FunnelStageInput | undefined,
index,
);
if (seen.has(entry.id)) continue;
sanitized.push(entry);
seen.add(entry.id);
}
if (sanitized.length < 2) return cloneDefaultStages();
return sanitized;
};
const sanitizeStageId = (value: unknown): string | null => {
if (typeof value !== "string") return null;
const normalized = normalizeSlug(value);
return normalized.length > 0 ? normalized : null;
};
const sanitizeDelta = (value: unknown): number | null => {
if (typeof value !== "number" || !Number.isFinite(value)) return null;
return Math.round(value);
};
const sanitizeValue = (value: unknown): number | null => {
if (typeof value !== "number" || !Number.isFinite(value)) return null;
const rounded = Math.round(value);
return rounded < 0 ? 0 : rounded;
};
const normalizeRatio = (value: number): number => {
if (!Number.isFinite(value)) return 0;
if (value < 0) return 0;
if (value > 1) return 1;
return Math.round(value * 1000) / 1000;
};
const formatPercent = (ratio: number): string => `${(ratio * 100).toFixed(1)}%`;
const updateStageCount = handler(
(
event: StageUpdateEvent | undefined,
context: {
stages: Cell;
history: Cell;
},
) => {
const stageId = sanitizeStageId(event?.stageId);
if (!stageId) return;
const current = sanitizeStages(context.stages.get());
const index = current.findIndex((entry) => entry.id === stageId);
if (index === -1) return;
const value = sanitizeValue(event?.value);
const delta = sanitizeDelta(event?.delta);
let nextCount = current[index].count;
let mode: "delta" | "value" | null = null;
if (value !== null) {
nextCount = value;
mode = "value";
} else if (delta !== null) {
nextCount = current[index].count + delta;
if (nextCount < 0) nextCount = 0;
mode = "delta";
}
if (mode === null || nextCount === current[index].count) return;
const updated = current.map((stage, position) =>
position === index ? { ...stage, count: nextCount } : stage
);
context.stages.set(updated);
const historyValue = context.history.get();
const historyList = Array.isArray(historyValue) ? historyValue : [];
const trimmed = historyList.slice(-4);
context.history.set([
...trimmed,
{
stageId: current[index].id,
label: current[index].label,
count: nextCount,
mode,
},
]);
},
);
const loadStageSnapshot = handler(
(
event: { stages?: FunnelStageInput[] } | undefined,
context: {
stages: Cell;
history: Cell;
},
) => {
if (!event?.stages) return;
const sanitized = sanitizeStages(event.stages);
context.stages.set(sanitized);
context.history.set([]);
},
);
export const funnelAnalytics = recipe(
"Funnel Analytics",
({ stages }) => {
const updateHistory = cell([]);
const stageList = lift(sanitizeStages)(stages);
const stageMetrics = lift((entries: FunnelStage[]) => {
if (!Array.isArray(entries) || entries.length === 0) return [];
const base = entries[0]?.count ?? 0;
let previous = base;
return entries.map((stage, index) => {
const dropOffBase = index === 0 ? 0 : previous;
const drop = dropOffBase === 0
? 0
: normalizeRatio((dropOffBase - stage.count) / dropOffBase);
const conversion = base === 0 ? 0 : normalizeRatio(stage.count / base);
previous = stage.count;
return {
id: stage.id,
label: stage.label,
count: stage.count,
dropOffRate: drop,
conversionRate: conversion,
dropOffPercent: formatPercent(drop),
conversionPercent: formatPercent(conversion),
};
});
})(stageList);
const dropOffDetails = lift((metrics: StageMetric[]) => {
if (!Array.isArray(metrics)) return [];
const details: FunnelDropOffDetail[] = [];
for (let index = 1; index < metrics.length; index++) {
const current = metrics[index];
const previous = metrics[index - 1];
const lost = previous.count - current.count;
details.push({
fromId: previous.id,
toId: current.id,
fromStage: previous.label,
toStage: current.label,
lost: lost > 0 ? lost : 0,
dropOffRate: current.dropOffRate,
dropOffPercent: current.dropOffPercent,
});
}
return details;
})(stageMetrics);
const stageOrder = lift((metrics: StageMetric[]) =>
Array.isArray(metrics) ? metrics.map((stage) => stage.id) : []
)(stageMetrics);
const overallConversionRate = lift((metrics: StageMetric[]) => {
if (!Array.isArray(metrics) || metrics.length === 0) return 0;
const last = metrics[metrics.length - 1];
return normalizeRatio(last.conversionRate);
})(stageMetrics);
const overallConversionPercent = lift((ratio: number) =>
formatPercent(normalizeRatio(ratio))
)(overallConversionRate);
const overallConversionLabel =
str`Overall conversion ${overallConversionPercent}`;
const worstStage = lift((metrics: StageMetric[] | undefined) => {
if (!Array.isArray(metrics) || metrics.length === 0) {
return { label: "No stages", dropOffPercent: "0.0%" };
}
if (metrics.length === 1) {
return {
label: metrics[0].label,
dropOffPercent: metrics[0].dropOffPercent,
};
}
let worst = metrics[1];
for (let index = 2; index < metrics.length; index++) {
const candidate = metrics[index];
if (candidate.dropOffRate > worst.dropOffRate) {
worst = candidate;
continue;
}
if (
candidate.dropOffRate === worst.dropOffRate &&
candidate.label.localeCompare(worst.label) < 0
) {
worst = candidate;
}
}
return { label: worst.label, dropOffPercent: worst.dropOffPercent };
})(stageMetrics);
const worstLabel = lift((entry: { label: string }) => entry.label)(
worstStage,
);
const worstPercent = lift(
(entry: { dropOffPercent: string }) => entry.dropOffPercent,
)(worstStage);
const dropOffSummary = str`${worstLabel} drop-off ${worstPercent}`;
const historyView = lift((entries: StageUpdateEntry[] | undefined) =>
Array.isArray(entries) ? entries : []
)(updateHistory);
const lastUpdate = lift((entries: StageUpdateEntry[]) => {
if (!Array.isArray(entries) || entries.length === 0) {
return { stageId: "none", label: "None", count: 0, mode: "delta" };
}
return entries[entries.length - 1];
})(historyView);
return {
stages,
stageMetrics,
dropOffDetails,
stageOrder,
overallConversionRate,
overallConversionPercent,
overallConversionLabel,
dropOffSummary,
updateHistory: historyView,
lastUpdate,
updateStage: updateStageCount({ stages, history: updateHistory }),
loadSnapshot: loadStageSnapshot({ stages, history: updateHistory }),
};
},
);
export type {
FunnelAnalyticsArgs,
FunnelDropOffDetail,
FunnelStage,
StageMetric,
StageUpdateEntry,
};