"use client"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type KeyboardEvent, type PointerEvent, useEffect, useMemo, useRef, useState } from "react"; import { Tabs, TabsList, TabsTab } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; export interface Cohort { label: string; size: number; retained: number[]; } export type CohortMode = "percent" | "count"; export interface CohortGridProps { title?: string; caption?: string; cohorts?: Cohort[]; periodPrefix?: string; periodName?: string; defaultMode?: CohortMode; color?: string; locale?: string; className?: string; } const CELL_W = 38; const CELL_H = 30; const LABEL_W = 56; const SIZE_W = 50; function mulberry(seed: number) { return () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function sampleCohorts(): Cohort[] { const rand = mulberry(42); const weeks = ["Aug 24", "Aug 31", "Sep 7", "Sep 14", "Sep 21", "Sep 28", "Oct 5", "Oct 12", "Oct 19"]; return weeks.map((label, i) => { const size = Math.round(980 + rand() * 420 + i * 24); const lift = 1 + i * 0.018; const retained = Array.from({ length: weeks.length - i }, (_, w) => { if (w === 0) return size; const curve = 0.2 + 0.3 * Math.exp(-(w - 1) / 2.4); return Math.round(size * Math.min(0.95, curve * lift * (0.93 + rand() * 0.14))); }); return { label, size, retained }; }); } const defaultCohorts = sampleCohorts(); export function CohortGrid({ title = "Retention by signup week", caption = "Active users each week after signing up", cohorts = defaultCohorts, periodPrefix = "W", periodName = "Week", defaultMode = "percent", color = "var(--color-violet-500)", locale = "en-US", className, }: CohortGridProps) { const reduce = useReducedMotion(); const wrapRef = useRef(null); const scrollRef = useRef(null); const [mode, setMode] = useState(defaultMode); const [active, setActive] = useState<{ r: number; c: number } | null>(null); const [tip, setTip] = useState({ x: 0, y: 0 }); const [announce, setAnnounce] = useState(""); const [moreRight, setMoreRight] = useState(false); useEffect(() => { const el = scrollRef.current; if (!el) return; const check = () => setMoreRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 2); check(); const ro = new ResizeObserver(check); ro.observe(el); el.addEventListener("scroll", check, { passive: true }); return () => { ro.disconnect(); el.removeEventListener("scroll", check); }; }, []); const num = useMemo(() => new Intl.NumberFormat(locale), [locale]); const periods = Math.max(...cohorts.map((c) => c.retained.length)); const avg = useMemo( () => Array.from({ length: periods }, (_, p) => { let kept = 0; let base = 0; for (const c of cohorts) { if (c.retained[p] === undefined) continue; kept += c.retained[p]; base += c.size; } return base ? kept / base : 0; }), [cohorts, periods], ); const rate = (r: number, c: number) => (r === cohorts.length ? avg[c] : cohorts[r].retained[c] / (cohorts[r].size || 1)); const has = (r: number, c: number) => (r === cohorts.length ? avg[c] > 0 : cohorts[r].retained[c] !== undefined); const maxLater = Math.max(...cohorts.flatMap((co) => co.retained.slice(1).map((v) => v / (co.size || 1))), 0.01); const strength = (r: number, c: number) => (c === 0 ? 1 : Math.min(1, rate(r, c) / maxLater)); const label = (r: number, c: number) => { if (mode === "percent") return `${Math.round(rate(r, c) * 100)}%`; if (r === cohorts.length) return `${Math.round(rate(r, c) * 100)}%`; const v = cohorts[r].retained[c]; return v >= 10000 ? `${Math.round(v / 1000)}k` : num.format(v); }; function describe(r: number, c: number) { if (r === cohorts.length) return `Average, ${periodName.toLowerCase()} ${c}: ${(avg[c] * 100).toFixed(1)}% retained`; const co = cohorts[r]; return `${co.label} cohort, ${periodName.toLowerCase()} ${c}: ${num.format(co.retained[c])} of ${num.format(co.size)} active, ${(rate(r, c) * 100).toFixed(1)}%`; } function placeAt(r: number, c: number) { const wrap = wrapRef.current; const scroller = scrollRef.current; const cell = wrap?.querySelector(`[data-cell="${r}-${c}"]`); if (!wrap || !cell || !scroller) return; const sb = scroller.getBoundingClientRect(); let b = cell.getBoundingClientRect(); const leftEdge = sb.left + LABEL_W + SIZE_W; if (b.left < leftEdge) scroller.scrollLeft -= leftEdge - b.left + 4; else if (b.right > sb.right) scroller.scrollLeft += b.right - sb.right + 4; b = cell.getBoundingClientRect(); const a = wrap.getBoundingClientRect(); setTip({ x: b.left - a.left + b.width / 2, y: b.top - a.top }); } function onPointerMove(e: PointerEvent) { const target = (e.target as HTMLElement).closest("[data-cell]"); if (!target || !wrapRef.current) { setActive(null); return; } const [r, c] = target.dataset.cell!.split("-").map(Number); if (active?.r === r && active?.c === c) return; const a = wrapRef.current.getBoundingClientRect(); const b = target.getBoundingClientRect(); setActive({ r, c }); setTip({ x: b.left - a.left + b.width / 2, y: b.top - a.top }); } function onKeyDown(e: KeyboardEvent) { const cur = active ?? { r: 0, c: 1 }; let { r, c } = cur; if (e.key === "ArrowRight") c += 1; else if (e.key === "ArrowLeft") c -= 1; else if (e.key === "ArrowDown") r += 1; else if (e.key === "ArrowUp") r -= 1; else if (e.key === "Home") c = 0; else if (e.key === "End") c = periods - 1; else if (e.key === "Escape") return setActive(null); else return; e.preventDefault(); r = Math.max(0, Math.min(cohorts.length, r)); c = Math.max(0, Math.min(periods - 1, c)); while (c > 0 && !has(r, c)) c -= 1; setActive({ r, c }); placeAt(r, c); setAnnounce(describe(r, c)); } const cellBg = (s: number) => `color-mix(in oklab, ${color} calc(8% + var(--lift) + ${s.toFixed(3)} * (72% - var(--lift))), var(--color-card))`; const rows = [...cohorts.map((c, i) => ({ key: c.label, i })), { key: "avg", i: cohorts.length }]; const wrapW = wrapRef.current?.clientWidth ?? 480; const below = tip.y < 120; const tipAlign = tip.x < 110 ? "start" : tip.x > wrapW - 110 ? "end" : "center"; const activeRate = active ? rate(active.r, active.c) : 0; const vsAvg = active && active.r < cohorts.length ? (activeRate - avg[active.c]) * 100 : null; return (

{title}

{caption}

setMode(v as CohortMode)} value={mode}> % Users
setActive(null)} onFocus={(e) => { if (e.target === e.currentTarget && !active) { setActive({ r: 0, c: 1 }); requestAnimationFrame(() => placeAt(0, 1)); } }} onKeyDown={onKeyDown} onPointerLeave={() => setActive(null)} onPointerMove={onPointerMove} ref={scrollRef} role="application" tabIndex={0} >
Cohort
Users
{Array.from({ length: periods }, (_, c) => (
{periodPrefix} {c}
))} {rows.map(({ key, i: r }) => { const isAvg = r === cohorts.length; const rowOn = active?.r === r; return (
{isAvg ?
: null}
{isAvg ? "Average" : cohorts[r].label}
{isAvg ? num.format(Math.round(cohorts.reduce((s, c) => s + c.size, 0) / (cohorts.length || 1))) : num.format(cohorts[r].size)}
{Array.from({ length: periods }, (_, c) => { if (!has(r, c)) return
; const s = c === 0 ? 0 : strength(r, c); const dim = active && active.r !== r && active.c !== c; const on = active?.r === r && active?.c === c; return (
{label(r, c)}
); })}
); })}
{active ? (
{active.r === cohorts.length ? "All cohorts" : `Joined week of ${cohorts[active.r].label}`} · {periodName} {active.c}
{active.r === cohorts.length ? ( <> {(activeRate * 100).toFixed(1)}% still active on average ) : ( <> {num.format(cohorts[active.r].retained[active.c])} of{" "} {num.format(cohorts[active.r].size)} active · {(activeRate * 100).toFixed(1)}% )}
{vsAvg !== null && active.c > 0 ? (
= 2 ? "text-success-foreground" : vsAvg <= -2 ? "text-destructive-foreground" : "text-muted-foreground", )} > {vsAvg >= 0 ? "+" : "−"} {Math.abs(vsAvg).toFixed(1)} pts vs average
) : null}
) : null}
{announce}
); }