Funnel Each step's drop is the finding, so the step-to-step percentage matters more than the absolute width. A funnel that only shows totals hides which stage is actually broken. Tokens this needs: --card, --card-foreground, --muted-foreground, --border, --status-warn, --chart-1 The status, chart, scale, state and direction names are an extension, not a rename. shadcn has --destructive and five --chart-* and nothing else in this territory. ──────────────────────────────────────────────────────────────────────── // Funnel.tsx ──────────────────────────────────────────────────────────────────────── import { cn } from "@/lib/utils"; /** A step is a name and how many of the cohort reached it. Both percentages * are computed here, so the chart can never show one without the other. */ export type Step = { name: string; count: number }; type Props = { /** In sequence. The first step is the population everything is measured against. */ steps: Step[]; /** The conversion window, printed under the chart. Somebody who started * Tuesday and paid Friday either counts or does not, and the chart has to * say which. */ window: string; barWidth?: number; }; const pct = (n: number) => `${Math.round(n * 100)}%`; const fmt = new Intl.NumberFormat("en-GB"); export function Funnel({ steps, window, barWidth = 160 }: Props) { const base = steps[0]?.count ?? 0; const rows = steps.map((s, i) => ({ ...s, ofAll: s.count / base, fromLast: i === 0 ? null : s.count / steps[i - 1].count, })); // The finding is the biggest single drop, and it gets the only colour. const worst = rows.reduce((w, r, i) => (r.fromLast !== null && (w < 0 || r.fromLast < rows[w].fromLast!) ? i : w), -1); return (
{fmt.format(base)} people, {window}