{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "since-you-left",
  "type": "registry:component",
  "title": "Since You Left",
  "description": "What moved while you were away, one metric per row: a tiny trend line, the number now, and a change chip that knows when down is the good news.",
  "categories": [
    "component",
    "Numbers"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/tabs.json"
  ],
  "files": [
    {
      "path": "registry/devl/since-you-left.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { ArrowUpIcon } from \"lucide-react\";\nimport { animate, type MotionValue, motion, useMotionValue, useReducedMotion, useTransform } from \"motion/react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { Tabs, TabsList, TabsTab } from \"@/components/ui/tabs\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface SinceYouLeftPeriod {\n  id: string;\n  label: string;\n  caption?: string;\n}\n\nexport interface SinceYouLeftMetric {\n  id: string;\n  label: string;\n  good?: \"up\" | \"down\";\n  format?: (value: number) => string;\n  series: Record<string, number[]>;\n}\n\nexport interface SinceYouLeftProps {\n  title?: string;\n  periods: SinceYouLeftPeriod[];\n  metrics: SinceYouLeftMetric[];\n  defaultPeriod?: string;\n  flatBelow?: number;\n  className?: string;\n}\n\nconst SAMPLES = 16;\nconst plain = (v: number) => new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 1 }).format(v);\n\nconst DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];\n\nfunction Face({ n, turn }: { n: number; turn: MotionValue<number> }) {\n  const y = useTransform(turn, (t) => {\n    let offset = (((n - t) % 10) + 10) % 10;\n    if (offset > 5) offset -= 10;\n    return `${offset * 100}%`;\n  });\n  return (\n    <motion.span className=\"absolute inset-x-0 top-0 block h-[1.15em] text-center leading-[1.15em]\" style={{ y }}>\n      {n}\n    </motion.span>\n  );\n}\n\nfunction Wheel({ digit, instant, delay }: { digit: number; instant: boolean; delay: number }) {\n  const reduce = useReducedMotion();\n  const turn = useMotionValue(digit);\n  const target = useRef(digit);\n  useEffect(() => {\n    let step = digit - (((target.current % 10) + 10) % 10);\n    if (step > 5) step -= 10;\n    else if (step < -5) step += 10;\n    target.current += step;\n    if (reduce || instant) turn.jump(target.current);\n    else if (step !== 0 || (!turn.isAnimating() && turn.get() !== target.current))\n      animate(turn, target.current, { type: \"spring\", stiffness: 280, damping: 34, delay });\n  }, [digit, instant, delay, reduce, turn]);\n  useEffect(() => () => turn.stop(), [turn]);\n  return (\n    <span className=\"relative inline-block h-[1.15em] overflow-hidden [mask-image:linear-gradient(transparent,black_20%,black_80%,transparent)]\">\n      <span className=\"invisible block leading-[1.15em]\">0</span>\n      {DIGITS.map((n) => (\n        <Face key={n} n={n} turn={turn} />\n      ))}\n    </span>\n  );\n}\n\nfunction Roll({\n  text,\n  className,\n  instant = false,\n  delay = 0,\n}: {\n  text: string;\n  className?: string;\n  instant?: boolean;\n  delay?: number;\n}) {\n  let place = 0;\n  let other = 0;\n  const glyphs = [...text]\n    .reverse()\n    .map((char) => {\n      const isDigit = char >= \"0\" && char <= \"9\";\n      return { char, isDigit, key: isDigit ? `d${place++}` : `s${other++}${char}` };\n    })\n    .reverse();\n  return (\n    <span className={cn(\"inline-flex whitespace-nowrap tabular-nums leading-none\", className)}>\n      <span className=\"sr-only\">{text}</span>\n      <span aria-hidden className=\"inline-flex\">\n        {glyphs.map((g) =>\n          g.isDigit ? (\n            <Wheel delay={delay} digit={Number(g.char)} instant={instant} key={g.key} />\n          ) : (\n            <span className=\"inline-block h-[1.15em] leading-[1.15em]\" key={g.key}>\n              {g.char === \" \" ? \" \" : g.char}\n            </span>\n          ),\n        )}\n      </span>\n    </span>\n  );\n}\n\nfunction resample(values: number[], n = SAMPLES) {\n  if (values.length === 0) return Array(n).fill(0);\n  if (values.length === 1) return Array(n).fill(values[0]);\n  return Array.from({ length: n }, (_, i) => {\n    const t = (i / (n - 1)) * (values.length - 1);\n    const i1 = Math.min(values.length - 2, Math.floor(t));\n    const u = t - i1;\n    const p0 = values[Math.max(0, i1 - 1)];\n    const p1 = values[i1];\n    const p2 = values[i1 + 1];\n    const p3 = values[Math.min(values.length - 1, i1 + 2)];\n    return (\n      0.5 *\n      (2 * p1 + (-p0 + p2) * u + (2 * p0 - 5 * p1 + 4 * p2 - p3) * u * u + (-p0 + 3 * p1 - 3 * p2 + p3) * u * u * u)\n    );\n  });\n}\n\nfunction paths(values: number[], w: number, h: number) {\n  const pts = resample(values);\n  const lo = Math.min(...pts);\n  const hi = Math.max(...pts);\n  const mid = (lo + hi) / 2;\n  const span = Math.max(hi - lo, Math.abs(mid) * 0.12) || 1;\n  const min = mid - span / 2;\n  const xy = pts.map((v, i) => [(i / (pts.length - 1)) * w, h - 2 - ((v - min) / span) * (h - 4)] as const);\n  let line = `M${xy[0][0].toFixed(2)},${xy[0][1].toFixed(2)}`;\n  for (let i = 1; i < xy.length; i++) {\n    const [x0, y0] = xy[i - 1];\n    const [x1, y1] = xy[i];\n    const [xp, yp] = xy[i - 2] ?? xy[i - 1];\n    const [xn, yn] = xy[i + 1] ?? xy[i];\n    const c1x = x0 + (x1 - xp) / 6;\n    const c1y = y0 + (y1 - yp) / 6;\n    const c2x = x1 - (xn - x0) / 6;\n    const c2y = y1 - (yn - y0) / 6;\n    line += ` C${c1x.toFixed(2)},${c1y.toFixed(2)} ${c2x.toFixed(2)},${c2y.toFixed(2)} ${x1.toFixed(2)},${y1.toFixed(2)}`;\n  }\n  const area = `${line} L${w},${h} L0,${h} Z`;\n  const end = xy[xy.length - 1];\n  return { line, area, end };\n}\n\ntype Mood = \"good\" | \"bad\" | \"flat\";\n\nconst moodText: Record<Mood, string> = {\n  good: \"text-success-foreground\",\n  bad: \"text-destructive-foreground\",\n  flat: \"text-muted-foreground\",\n};\nconst moodBg: Record<Mood, string> = {\n  good: \"bg-success/10\",\n  bad: \"bg-destructive/10\",\n  flat: \"bg-muted\",\n};\nconst moodStroke: Record<Mood, string> = {\n  good: \"var(--color-success)\",\n  bad: \"var(--color-destructive)\",\n  flat: \"var(--color-muted-foreground)\",\n};\n\nfunction MetricRow({\n  metric,\n  periodId,\n  flatBelow,\n  index,\n}: {\n  metric: SinceYouLeftMetric;\n  periodId: string;\n  flatBelow: number;\n  index: number;\n}) {\n  const reduce = useReducedMotion();\n  const gradientId = `change-${useId().replace(/:/g, \"\")}`;\n  const values = metric.series[periodId] ?? [];\n  const first = values[0] ?? 0;\n  const last = values[values.length - 1] ?? 0;\n  const delta = first ? (last - first) / Math.abs(first) : 0;\n  const direction = Math.abs(delta) < flatBelow ? \"flat\" : delta > 0 ? \"up\" : \"down\";\n  const mood: Mood = direction === \"flat\" ? \"flat\" : direction === (metric.good ?? \"up\") ? \"good\" : \"bad\";\n  const format = metric.format ?? plain;\n\n  const W = 72;\n  const H = 26;\n  const { line, area, end } = paths(values, W, H);\n  const spring = reduce ? { duration: 0 } : ({ type: \"spring\", duration: 0.5, bounce: 0 } as const);\n  const rotate = direction === \"up\" ? 0 : direction === \"down\" ? 180 : 90;\n  const sign = delta > 0 ? \"+\" : delta < 0 ? \"−\" : \"\";\n  const deltaText = `${sign}${Math.abs(delta * 100).toFixed(1)}%`;\n  const words = `${metric.label}: ${format(last)}, ${direction === \"flat\" ? \"about the same\" : `${direction} ${deltaText.replace(\"−\", \"\")}`} from ${format(first)}`;\n\n  return (\n    <motion.li\n      animate={{ opacity: 1, y: 0 }}\n      aria-label={words}\n      className=\"group/row grid grid-cols-[minmax(0,1fr)_auto_auto_auto] items-center gap-2 rounded-xl px-2 py-2 transition-colors hover:bg-accent @sm:gap-3\"\n      initial={{ opacity: 0, y: 6 }}\n      transition={reduce ? { duration: 0 } : { type: \"spring\", duration: 0.4, bounce: 0, delay: Math.min(index, 8) * 0.03 }}\n    >\n      <span aria-hidden className=\"truncate text-muted-foreground text-sm\">\n        {metric.label}\n      </span>\n      <svg aria-hidden className=\"h-6.5 w-12 overflow-visible @sm:w-18\" viewBox={`0 0 ${W} ${H}`}>\n        <defs>\n          <linearGradient id={gradientId} x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">\n            <stop offset=\"0%\" style={{ stopColor: moodStroke[mood], stopOpacity: 0.22 }} />\n            <stop offset=\"100%\" style={{ stopColor: moodStroke[mood], stopOpacity: 0 }} />\n          </linearGradient>\n        </defs>\n        <motion.path animate={{ d: area }} fill={`url(#${gradientId})`} initial={false} transition={spring} />\n        <motion.path\n          animate={{ d: line, stroke: moodStroke[mood] }}\n          fill=\"none\"\n          initial={false}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth={1.5}\n          transition={spring}\n        />\n        <motion.circle\n          animate={{ cx: end[0], cy: end[1], fill: moodStroke[mood] }}\n          className=\"opacity-0 transition-opacity group-hover/row:opacity-100\"\n          initial={false}\n          r={2.5}\n          transition={spring}\n        />\n      </svg>\n      <Roll className=\"min-w-12 justify-end font-medium text-sm @sm:min-w-14\" delay={Math.min(index, 8) * 0.03} text={format(last)} />\n      <span\n        aria-hidden\n        className={cn(\n          \"inline-flex min-w-19 items-center justify-end gap-1 rounded-full py-0.5 ps-1 pe-2 font-medium text-xs transition-colors duration-300\",\n          moodBg[mood],\n          moodText[mood],\n        )}\n      >\n        <motion.span\n          animate={{ rotate }}\n          className=\"inline-flex\"\n          initial={false}\n          transition={reduce ? { duration: 0 } : { type: \"spring\", duration: 0.4, bounce: 0 }}\n        >\n          <ArrowUpIcon className=\"size-3.5\" strokeWidth={2.5} />\n        </motion.span>\n        <Roll delay={Math.min(index, 8) * 0.03} text={deltaText} />\n      </span>\n    </motion.li>\n  );\n}\n\nexport function SinceYouLeft({\n  title = \"What changed\",\n  periods,\n  metrics,\n  defaultPeriod,\n  flatBelow = 0.005,\n  className,\n}: SinceYouLeftProps) {\n  const [periodId, setPeriodId] = useState(defaultPeriod ?? periods[0]?.id);\n  const period = periods.find((p) => p.id === periodId) ?? periods[0];\n\n  const tally = { good: 0, bad: 0, flat: 0 };\n  for (const m of metrics) {\n    const v = m.series[period.id] ?? [];\n    const first = v[0] ?? 0;\n    const delta = first ? ((v[v.length - 1] ?? 0) - first) / Math.abs(first) : 0;\n    if (Math.abs(delta) < flatBelow) tally.flat++;\n    else if ((delta > 0 ? \"up\" : \"down\") === (m.good ?? \"up\")) tally.good++;\n    else tally.bad++;\n  }\n\n  return (\n    <section\n      aria-label={title}\n      className={cn(\"flex w-full max-w-[26rem] flex-col gap-3 rounded-2xl border bg-card p-3 text-card-foreground shadow-xs/5\", className)}\n    >\n      <header className=\"flex flex-col gap-3 px-2 pt-1\">\n        <div className=\"flex flex-wrap items-start justify-between gap-x-3 gap-y-2\">\n          <div className=\"flex min-w-0 flex-col gap-0.5\">\n            <h3 className=\"text-balance font-medium text-sm\">{title}</h3>\n            <p className=\"text-muted-foreground text-xs\">{period.caption ?? period.label}</p>\n          </div>\n          <p aria-live=\"polite\" className=\"flex items-center gap-2.5 pt-0.5 text-xs\">\n            <span className=\"inline-flex items-center gap-1 text-success-foreground\">\n              <span className=\"size-1.5 rounded-full bg-success\" />\n              <Roll text={String(tally.good)} />\n              <span className=\"text-muted-foreground\">better</span>\n            </span>\n            <span className=\"inline-flex items-center gap-1 text-destructive-foreground\">\n              <span className=\"size-1.5 rounded-full bg-destructive\" />\n              <Roll text={String(tally.bad)} />\n              <span className=\"text-muted-foreground\">worse</span>\n            </span>\n            <span className=\"inline-flex items-center gap-1 text-muted-foreground\">\n              <span className=\"size-1.5 rounded-full bg-muted-foreground/60\" />\n              <Roll text={String(tally.flat)} />\n              <span className=\"text-muted-foreground\">flat</span>\n            </span>\n          </p>\n        </div>\n        <Tabs onValueChange={(v) => setPeriodId(String(v))} value={period.id}>\n          <TabsList className=\"w-full text-muted-foreground\" size=\"sm\">\n            {periods.map((p) => (\n              <TabsTab className=\"text-xs sm:text-xs\" key={p.id} value={p.id}>\n                {p.label}\n              </TabsTab>\n            ))}\n          </TabsList>\n        </Tabs>\n      </header>\n      <ul className=\"@container flex flex-col\">\n        {metrics.map((m, i) => (\n          <MetricRow flatBelow={flatBelow} index={i} key={m.id} metric={m} periodId={period.id} />\n        ))}\n      </ul>\n    </section>\n  );\n}\n"
    }
  ]
}