{ "name": "attribution", "type": "registry:ui", "title": "Attribution", "description": "Credit for a conversion split across touchpoints, with the model as a control rather than a buried setting — because the disagreement between models is the whole finding.", "category": "Analytics", "dependencies": [], "registryDependencies": [ "lib-motion", "lib-styles", "lib-utils", "segmented-control" ], "files": [ { "path": "components/ui/attribution.tsx", "type": "registry:ui", "content": "'use client'\n\nimport { useId, useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { SegmentedControl } from '@/components/ui/segmented-control'\nimport { enterFade, growIn } from '@/lib/motion'\nimport { dataPalette } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Credit for a conversion, split across the touchpoints that led to it.\n *\n * **The model is a control, not a setting buried in a config.** Last-touch says\n * paid search won; first-touch says the blog post did; linear says everyone\n * helped. These are the *same journeys* — the disagreement is entirely in the\n * rule, and any single number presented without its model is a claim\n * masquerading as a measurement. Switching models in place is the point of the\n * component: a channel whose share collapses when you change the rule was never\n * really driving conversions.\n *\n * **Five models, computed here from the raw journeys:**\n * - *First touch* — all credit to the first interaction.\n * - *Last touch* — all credit to the last. The default in most tools, and the\n * most flattering to bottom-of-funnel spend.\n * - *Linear* — split evenly. No assumption, which is its virtue and its flaw.\n * - *Time decay* — later touches earn more, on a configurable half-life.\n * - *Position based* — 40/20/40 across first, middle and last.\n *\n * **None of them are causal.** Every model here divides observed credit among\n * touchpoints that were present; none establishes that a touchpoint *caused*\n * anything, and none can see the channels that never got a click. Incrementality\n * testing answers that question; this one shows you how sensitive your answer is\n * to an arbitrary rule, which is the honest thing a chart can do.\n */\nexport type Touchpoint = {\n channel: string\n at: Date | string\n}\n\nexport type Journey = {\n id: string\n touchpoints: Touchpoint[]\n /** Revenue or count. Defaults to 1 conversion. */\n value?: number\n}\n\nexport type AttributionModel = 'first' | 'last' | 'linear' | 'decay' | 'position'\n\ntype AttributionProps = Omit, 'onChange'> & {\n journeys: Journey[]\n model?: AttributionModel\n defaultModel?: AttributionModel\n onModelChange?: (model: AttributionModel) => void\n /** Models offered in the switcher. */\n models?: AttributionModel[]\n /** Half-life in days for time decay. */\n halfLife?: number\n valueFormat?: (value: number) => string\n emptyLabel?: string\n label?: string\n footnote?: ReactNode\n}\n\nconst MODEL_LABELS: Record = {\n first: 'First touch',\n last: 'Last touch',\n linear: 'Linear',\n decay: 'Time decay',\n position: 'Position based',\n}\n\nconst asTime = (value: Date | string) =>\n (value instanceof Date ? value : new Date(value)).getTime()\n\n/** Weights for one journey, summing to 1. */\nfunction weightsFor(journey: Journey, model: AttributionModel, halfLife: number): number[] {\n const points = journey.touchpoints\n const n = points.length\n if (n === 0) return []\n if (n === 1) return [1]\n\n switch (model) {\n case 'first':\n return points.map((_, index) => (index === 0 ? 1 : 0))\n case 'last':\n return points.map((_, index) => (index === n - 1 ? 1 : 0))\n case 'linear':\n return points.map(() => 1 / n)\n case 'position': {\n // 40% first, 40% last, 20% shared by the middle.\n if (n === 2) return [0.5, 0.5]\n const middle = 0.2 / (n - 2)\n return points.map((_, index) => (index === 0 || index === n - 1 ? 0.4 : middle))\n }\n case 'decay': {\n const last = asTime(points[n - 1].at)\n const dayMs = 86_400_000\n const raw = points.map((point) => {\n const daysBefore = (last - asTime(point.at)) / dayMs\n return Math.pow(2, -daysBefore / halfLife)\n })\n const sum = raw.reduce((total, weight) => total + weight, 0) || 1\n return raw.map((weight) => weight / sum)\n }\n }\n}\n\nfunction Attribution({\n journeys,\n model: modelProp,\n defaultModel = 'last',\n onModelChange,\n models = ['first', 'last', 'linear', 'decay', 'position'],\n halfLife = 7,\n valueFormat = (value) => value.toFixed(1),\n emptyLabel = 'No journeys.',\n label = 'Attribution',\n footnote,\n className,\n ...props\n}: AttributionProps) {\n const titleId = useId()\n const [internal, setInternal] = useState(defaultModel)\n const model = modelProp ?? internal\n\n const rows = useMemo(() => {\n const credit = new Map()\n let total = 0\n\n for (const journey of journeys) {\n const value = journey.value ?? 1\n const weights = weightsFor(journey, model, halfLife)\n journey.touchpoints.forEach((point, index) => {\n credit.set(point.channel, (credit.get(point.channel) ?? 0) + value * (weights[index] ?? 0))\n })\n total += value\n }\n\n return {\n total,\n channels: [...credit.entries()]\n .map(([channel, amount]) => ({ channel, amount }))\n .sort((a, b) => b.amount - a.amount),\n }\n }, [journeys, model, halfLife])\n\n if (journeys.length === 0) {\n return (\n
\n {emptyLabel}\n
\n )\n }\n\n const max = Math.max(...rows.channels.map((row) => row.amount), 1)\n\n const setModel = (next: AttributionModel) => {\n if (modelProp === undefined) setInternal(next)\n onModelChange?.(next)\n }\n\n return (\n \n
\n

\n {label}\n

\n\n {/* The model belongs on the chart, not in a settings panel: the number\n means nothing without it.\n\n The kit's SegmentedControl rather than a row of pills: this picks a\n value from a handful of visible options, which is exactly what that\n component is, and a hand-rolled radiogroup here had none of its\n roving focus or its sliding indicator. */}\n setModel(next as AttributionModel)}\n options={models.map((option) => ({\n value: option,\n label: MODEL_LABELS[option],\n }))}\n />\n
\n\n
    \n {rows.channels.map((row, index) => {\n const share = rows.total > 0 ? row.amount / rows.total : 0\n return (\n
  • \n {row.channel}\n \n \n \n \n {valueFormat(row.amount)}\n ({(share * 100).toFixed(0)}%)\n \n
  • \n )\n })}\n
\n\n

\n {footnote ?? (\n <>\n {journeys.length.toLocaleString()} journeys under {MODEL_LABELS[model].toLowerCase()}.\n Attribution divides observed credit; it does not establish cause.\n \n )}\n

\n \n )\n}\n\nexport { Attribution }\nexport type { AttributionProps }\n" } ] }