{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "metric-card",
  "title": "Metric card",
  "description": "A headline metric card with a smooth line (or bar) chart washed into its right side, a trend badge coloured by direction, a line/bar toggle, and a period dropdown with an optional custom-range calendar.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "https://bash-ui-registry.vercel.app/r/date-range-calendar.json"
  ],
  "files": [
    {
      "path": "registry/items/metric-card/metric-card.tsx",
      "content": "\"use client\";\n\nimport { useId, useMemo, useState } from \"react\";\nimport { ArrowDown, ArrowRight, ArrowUp } from \"lucide-react\";\nimport { motion } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  ACCENTS,\n  EASE,\n  formatCompact,\n  formatPointDate,\n  MetricChart,\n  SERIES_COLORS,\n  type ChartSeries,\n  type ChartView,\n  type MetricAccent,\n  type MetricSeries,\n  type SeriesPoint,\n} from \"./metric-chart\";\nimport { PeriodSelect, ViewToggle, type PeriodOption } from \"./metric-controls\";\n\n// Re-exported so consumers import this file only.\nexport type { SeriesPoint, MetricSeries, MetricAccent, ChartView, PeriodOption };\n\nexport type FooterStat = { label: string; value: number | string };\n\nexport type CardSize = \"sm\" | \"md\" | \"lg\";\n\nexport interface MetricCardProps {\n  title: string;\n  /** The headline. Defaults to the compact sum of the visible primary series. */\n  total?: string | number;\n  /** Footer delta. Defaults to the change between the last two points. */\n  delta?: string;\n  deltaLabel?: string;\n  /** Trend badge. Defaults to the % change across the window (or a count from zero). */\n  percent?: string;\n  /** Forces the trend direction; otherwise read from first vs last point. */\n  trend?: \"up\" | \"down\";\n  /** Appended to exact values in the tooltip (\"1,204 clients\"). */\n  unit?: string;\n  /** Label of the period selected at first render. */\n  period?: string;\n  periodOptions?: PeriodOption[];\n  onPeriodChange?: (option: PeriodOption) => void;\n  defaultView?: ChartView;\n  /** Fixed colour. Omit to colour by direction: go up, stop down, neutral flat. */\n  accent?: MetricAccent;\n  /** Single series. Supply this OR `series`. */\n  data?: SeriesPoint[];\n  /** Several named series. Takes priority over `data`. */\n  series?: MetricSeries[];\n  /** Point the cursor rests on when nothing is hovered. Default: the last. */\n  defaultIndex?: number;\n  size?: CardSize;\n  /** Show the secondary stats in the footer. */\n  showStats?: boolean;\n  /**\n   * Replaces peak / low / avg on the right of the footer.\n   * - \"series\": the sum of every series after the first.\n   * - FooterStat[]: an explicit list.\n   * - function: derived from the windowed series.\n   */\n  footerStats?: \"series\" | FooterStat[] | ((visibleSeries: MetricSeries[]) => FooterStat[]);\n  showDelta?: boolean;\n  /** False hides the footer bar entirely. */\n  showFooter?: boolean;\n  showAxes?: boolean;\n  showXAxis?: boolean;\n  showYAxis?: boolean;\n  /** Offer a \"Custom range\" calendar step in the period dropdown. */\n  allowCustomRange?: boolean;\n  /** Value formatting. Default: compact in the headline, exact in the tooltip. */\n  valueFormatter?: (value: number) => string;\n  dateFormatter?: (date: string) => string;\n  loading?: boolean;\n  /** Chart and dot grid span the whole card instead of its right 62%. */\n  fullWidth?: boolean;\n  /** Empty-state copy, shown when the window holds fewer than two points. */\n  emptyTitle?: string;\n  emptyDescription?: string;\n  className?: string;\n}\n\nconst DEFAULT_PERIODS: PeriodOption[] = [\n  { label: \"Past 7 days\", points: 7 },\n  { label: \"Past 14 days\", points: 14 },\n  { label: \"Past 30 days\" },\n];\n\n// Share of the card (from the right) the chart region occupies.\nconst REGION_W = 62; // %\n// A change under this reads as \"stable\" → neutral accent.\nconst NEUTRAL_PCT = 0.5;\n\nconst SIZES: Record<\n  CardSize,\n  { minH: string; pad: string; footer: string; title: string; headline: string; headlineH: string }\n> = {\n  sm: {\n    minH: \"min-h-[220px] sm:min-h-[260px]\",\n    pad: \"px-5 pt-5 sm:px-6\",\n    footer: \"px-5 py-3 sm:px-6\",\n    title: \"text-[15px]\",\n    headline: \"text-[38px] sm:text-[46px]\",\n    headlineH: \"h-[38px] sm:h-[46px]\",\n  },\n  md: {\n    minH: \"min-h-[300px] sm:min-h-[380px]\",\n    pad: \"px-5 pt-6 sm:px-8 sm:pt-7\",\n    footer: \"px-5 py-3 sm:px-8 sm:py-4\",\n    title: \"text-[16px] sm:text-[17px]\",\n    headline: \"text-[46px] sm:text-[72px]\",\n    headlineH: \"h-[46px] sm:h-[72px]\",\n  },\n  lg: {\n    minH: \"min-h-[320px] sm:min-h-[460px]\",\n    pad: \"px-5 pt-6 sm:px-10 sm:pt-9\",\n    footer: \"px-5 py-4 sm:px-10 sm:py-5\",\n    title: \"text-[26px] sm:text-[29px]\",\n    headline: \"text-[48px] sm:text-[88px]\",\n    headlineH: \"h-12 sm:h-[88px]\",\n  },\n};\n\n/**\n * Trim a series to the selected window. A preset (`points`) keeps the trailing\n * N points; explicit `from`/`to` filter inclusively and win over `points`.\n */\nconst sliceWindow = (points: SeriesPoint[], n?: number, from?: string, to?: string) => {\n  let out = points;\n  if (from) out = out.filter((p) => p.date >= from);\n  if (to) out = out.filter((p) => p.date <= to);\n  if (!from && !to && n && n < out.length) out = out.slice(-n);\n  return out;\n};\n\nconst DOT_MASK_FULL = \"linear-gradient(to right, transparent 2%, black 15%, black 85%, transparent 98%)\";\nconst DOT_MASK_REGION = \"linear-gradient(to right, transparent, black 55%)\";\n\nexport function MetricCard({\n  title,\n  total,\n  delta,\n  deltaLabel = \"today\",\n  percent,\n  trend,\n  unit,\n  period = \"Past 30 days\",\n  periodOptions,\n  onPeriodChange,\n  defaultView = \"curve\",\n  accent,\n  data,\n  series,\n  defaultIndex,\n  size = \"md\",\n  showStats = true,\n  footerStats: footerStatsProp,\n  showDelta = true,\n  showFooter = true,\n  showAxes = false,\n  showXAxis,\n  showYAxis,\n  allowCustomRange = false,\n  valueFormatter,\n  dateFormatter,\n  loading = false,\n  fullWidth = false,\n  emptyTitle = \"No data yet\",\n  emptyDescription = \"Metrics will appear once data is available.\",\n  className,\n}: MetricCardProps) {\n  const uid = useId().replace(/:/g, \"\");\n  const gridId = `grid-${uid}`;\n  const regionWidth = fullWidth ? \"100%\" : `${REGION_W}%`;\n  const hasFooter = showFooter && (showDelta || showStats);\n  const sz = SIZES[size];\n  const shell = cn(\n    \"relative flex w-full flex-col rounded-panel border border-rule bg-card font-body shadow-sm\",\n    sz.minH,\n    className,\n  );\n\n  const periods = periodOptions ?? DEFAULT_PERIODS;\n  // The whole option is kept (not just its label): an applied custom range is\n  // not one of the presets and carries its own from/to.\n  const defaultPeriod = periods.find((p) => p.label === period) ?? periods[periods.length - 1];\n  const [selected, setSelected] = useState<PeriodOption>(() => defaultPeriod);\n  const [view, setView] = useState<ChartView>(defaultView);\n\n  const baseSeries: MetricSeries[] = useMemo(\n    () => (series?.length ? series : [{ name: title, data: data ?? [], accent }]),\n    [series, data, title, accent],\n  );\n\n  const visibleSeries = useMemo(\n    () =>\n      baseSeries.map((s) => ({\n        ...s,\n        data: sliceWindow(s.data, selected.points, selected.from, selected.to),\n      })),\n    [baseSeries, selected],\n  );\n\n  // Calendar bounds from the UNWINDOWED series, so the window can widen again.\n  const seriesBounds = useMemo(() => {\n    let min: string | null = null;\n    let max: string | null = null;\n    for (const entry of baseSeries) {\n      for (const point of entry.data) {\n        if (min === null || point.date < min) min = point.date;\n        if (max === null || point.date > max) max = point.date;\n      }\n    }\n    return { min, max };\n  }, [baseSeries]);\n\n  const primary = visibleSeries[0];\n  const isMulti = visibleSeries.length > 1;\n  const hasData = (primary?.data.length ?? 0) >= 2;\n\n  // Every figure derives from the primary series, so the card stays coherent\n  // across a period change. Explicit props still win.\n  const stats = useMemo(() => {\n    const vals = primary?.data.map((d) => d.value) ?? [];\n    const sum = vals.reduce((a, b) => a + b, 0);\n    const first = vals[0] ?? 0;\n    const last = vals[vals.length - 1] ?? 0;\n    const prev = vals[vals.length - 2] ?? first;\n    const net = last - first;\n    return {\n      sum,\n      net,\n      first,\n      pct: first ? (net / first) * 100 : 0,\n      step: last - prev,\n      peak: vals.length ? Math.max(...vals) : 0,\n      low: vals.length ? Math.min(...vals) : 0,\n      avg: vals.length ? sum / vals.length : 0,\n    };\n  }, [primary]);\n\n  const resolvedFooterStats = useMemo<FooterStat[]>(() => {\n    if (Array.isArray(footerStatsProp)) return footerStatsProp;\n    if (typeof footerStatsProp === \"function\") return footerStatsProp(visibleSeries);\n    if (footerStatsProp === \"series\") {\n      return visibleSeries.slice(1).map((s) => ({\n        label: s.footerLabel ?? s.name.toLowerCase(),\n        value: s.data.reduce((acc, point) => acc + point.value, 0),\n      }));\n    }\n    return [];\n  }, [footerStatsProp, visibleSeries]);\n\n  // A window starting at zero has no percentage to quote, so it states a count.\n  const fromZero = stats.first === 0 && stats.net !== 0;\n\n  const resolvedTrend: \"up\" | \"down\" | \"flat\" =\n    trend ??\n    (fromZero\n      ? stats.net >= 0\n        ? \"up\"\n        : \"down\"\n      : Math.abs(stats.pct) < NEUTRAL_PCT\n        ? \"flat\"\n        : stats.net >= 0\n          ? \"up\"\n          : \"down\");\n  const resolvedAccent: MetricAccent =\n    accent ?? (resolvedTrend === \"up\" ? \"go\" : resolvedTrend === \"down\" ? \"stop\" : \"neutral\");\n  const color = ACCENTS[resolvedAccent];\n  const TrendIcon = resolvedTrend === \"flat\" ? ArrowRight : resolvedTrend === \"down\" ? ArrowDown : ArrowUp;\n\n  const fmtCompact = valueFormatter ?? formatCompact;\n  const fmtFull = valueFormatter ?? ((n: number) => n.toLocaleString() + (unit ? ` ${unit}` : \"\"));\n  const fmtDate = dateFormatter ?? formatPointDate;\n  const sign = (n: number) => (n >= 0 ? \"+\" : \"−\") + fmtCompact(Math.abs(n));\n\n  const displayTotal = total ?? fmtCompact(stats.sum);\n  const displayDelta = delta ?? sign(stats.step);\n  const displayPercent = percent ?? (fromZero ? sign(stats.net) : `${Math.abs(stats.pct).toFixed(1)}%`);\n\n  // Each series' colour: explicit colour → accent → palette → the headline colour.\n  const chartSeries: ChartSeries[] = visibleSeries.map((s, i) => ({\n    name: s.name,\n    data: s.data,\n    color:\n      s.color ??\n      (s.accent ? ACCENTS[s.accent].stroke : isMulti ? SERIES_COLORS[i % SERIES_COLORS.length] : color.stroke),\n  }));\n\n  const lastIndex = (primary?.data.length ?? 1) - 1;\n  const fallback = Math.min(defaultIndex ?? lastIndex, lastIndex);\n\n  const handlePeriodChange = (option: PeriodOption) => {\n    setSelected(option);\n    onPeriodChange?.(option);\n  };\n\n  if (loading) {\n    // The loaded card's own layout, bar for bar. The title bar holds the real\n    // string invisibly, so a wrapped title reserves exactly its lines.\n    return (\n      <div className={shell} aria-busy=\"true\">\n        <div className={`relative z-20 flex flex-1 flex-col ${sz.pad}`}>\n          <div className=\"flex flex-wrap items-center justify-between gap-x-4 gap-y-2\">\n            <div className=\"flex min-w-0 items-center gap-3\">\n              <span className={`block w-fit max-w-full animate-pulse rounded bg-muted ${sz.title}`}>\n                <span className=\"invisible select-none\">{title}</span>\n              </span>\n              <span className=\"block h-[30px] w-16 shrink-0 animate-pulse rounded-full bg-muted\" />\n            </div>\n            <div className=\"flex items-center gap-3.5\">\n              <span className=\"block h-4 w-16 animate-pulse rounded bg-muted\" />\n              <span className=\"block h-7 w-28 animate-pulse rounded-full bg-muted\" />\n            </div>\n          </div>\n          <span className={`mt-5 block w-fit max-w-full animate-pulse rounded-lg bg-muted ${sz.headlineH}`}>\n            <span className=\"invisible leading-none select-none\">000</span>\n          </span>\n          <div className=\"mt-auto h-24 w-full animate-pulse rounded-lg bg-muted/50\" />\n        </div>\n        {hasFooter && (\n          <div\n            className={`relative z-10 flex items-center justify-between gap-4 rounded-b-[inherit] border-t border-rule-soft bg-card ${sz.footer}`}\n          >\n            <span className=\"block h-4 w-32 animate-pulse rounded bg-muted\" />\n            <span className=\"block h-4 w-40 animate-pulse rounded bg-muted\" />\n          </div>\n        )}\n      </div>\n    );\n  }\n\n  if (!hasData) {\n    return (\n      <div className={shell}>\n        <div className={`flex flex-1 flex-col ${sz.pad}`}>\n          <h3 className={`${sz.title} font-semibold tracking-tight text-foreground`}>{title}</h3>\n          <div className=\"flex flex-1 flex-col items-center justify-center gap-1 py-10 text-center\">\n            <p className=\"text-sm font-medium text-foreground\">{emptyTitle}</p>\n            <p className=\"text-xs text-muted-foreground\">{emptyDescription}</p>\n          </div>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className={shell}>\n      {/* Plot area: a sibling of the footer, so the chart's floor lands on the\n          footer's top border instead of behind it. */}\n      <div\n        className={cn(\n          \"relative flex min-h-0 flex-1 flex-col rounded-t-[inherit]\",\n          !hasFooter && \"rounded-b-[inherit]\",\n        )}\n      >\n        {/* Clipped background (wash + dot grid), inheriting the card's radius,\n            so the outer shell can stay overflow-visible for the dropdown. */}\n        <div className=\"pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]\">\n          <div className=\"absolute inset-y-0 right-0\" style={{ width: regionWidth }}>\n            <div\n              className=\"absolute inset-0\"\n              style={{\n                background: fullWidth\n                  ? `linear-gradient(to top, color-mix(in oklab, ${color.stroke} 9.4%, transparent), transparent 75%)`\n                  : `linear-gradient(to left, color-mix(in oklab, ${color.stroke} 12.2%, transparent), transparent 75%)`,\n              }}\n            />\n            <div\n              className=\"absolute inset-0 text-faint\"\n              style={{\n                WebkitMaskImage: fullWidth ? DOT_MASK_FULL : DOT_MASK_REGION,\n                maskImage: fullWidth ? DOT_MASK_FULL : DOT_MASK_REGION,\n              }}\n            >\n              <svg className=\"h-full w-full\" aria-hidden>\n                <defs>\n                  <pattern id={gridId} width=\"14\" height=\"14\" patternUnits=\"userSpaceOnUse\">\n                    <circle cx=\"1\" cy=\"1\" r=\"1\" fill=\"currentColor\" />\n                  </pattern>\n                </defs>\n                <rect width=\"100%\" height=\"100%\" fill={`url(#${gridId})`} />\n              </svg>\n            </div>\n          </div>\n        </div>\n\n        {/* Chart region: overflow-visible so the tooltip is never clipped. */}\n        <div\n          className=\"absolute inset-y-0 right-0 overflow-visible rounded-r-[inherit]\"\n          style={{ width: regionWidth }}\n        >\n          <MetricChart\n            series={chartSeries}\n            view={view}\n            defaultIndex={fallback}\n            valueFormatter={fmtFull}\n            dateFormatter={fmtDate}\n            compactFormatter={fmtCompact}\n            bandTop={fullWidth ? 42 : undefined}\n            showAxes={showAxes}\n            showXAxis={showXAxis}\n            showYAxis={showYAxis}\n            className=\"rounded-r-[inherit]\"\n          />\n        </div>\n\n        <div\n          className={cn(\n            \"pointer-events-none relative z-20 flex flex-1 flex-col\",\n            sz.pad,\n            !hasFooter && (size === \"lg\" ? \"pb-9\" : size === \"md\" ? \"pb-7\" : \"pb-5\"),\n          )}\n        >\n          <div className=\"flex flex-wrap items-center justify-between gap-x-4 gap-y-2\">\n            <div className=\"flex min-w-0 items-center gap-3\">\n              <h3 className={`${sz.title} font-semibold tracking-tight text-foreground`}>{title}</h3>\n              <ViewToggle value={view} onChange={setView} layoutId={`view-toggle-${uid}`} />\n            </div>\n            <div className=\"flex items-center gap-3.5 text-[14px]\">\n              <motion.span\n                key={`trend-${selected.label}`}\n                initial={{ opacity: 0, y: -3 }}\n                animate={{ opacity: 1, y: 0 }}\n                transition={{ duration: 0.25 }}\n                className={cn(\n                  \"flex items-center gap-1 whitespace-nowrap rounded-full bg-card/70 px-2.5 py-1 font-semibold tabular-nums backdrop-blur-sm\",\n                  resolvedTrend === \"up\" ? \"text-go\" : resolvedTrend === \"down\" ? \"text-stop\" : \"text-dim\",\n                )}\n              >\n                <TrendIcon size={16} strokeWidth={2.5} />\n                {displayPercent}\n              </motion.span>\n              <PeriodSelect\n                value={selected.label}\n                options={periods}\n                onChange={handlePeriodChange}\n                accentText={color.text}\n                allowCustomRange={allowCustomRange}\n                defaultOption={defaultPeriod}\n                rangeMin={seriesBounds.min}\n                rangeMax={seriesBounds.max}\n              />\n            </div>\n          </div>\n\n          {isMulti && (\n            <div className=\"mt-2.5 flex flex-wrap items-center gap-x-4 gap-y-1\">\n              {chartSeries.map((s) => (\n                <span key={s.name} className=\"flex items-center gap-1.5 text-[12px] text-muted-foreground\">\n                  <span className=\"h-2 w-2 rounded-full\" style={{ background: s.color }} />\n                  {s.name}\n                </span>\n              ))}\n            </div>\n          )}\n\n          <motion.div\n            key={`headline-${selected.label}`}\n            initial={{ opacity: 0, y: 6 }}\n            animate={{ opacity: 1, y: 0 }}\n            transition={{ duration: 0.3, ease: EASE }}\n            className={`mt-5 ${sz.headline} font-medium leading-none tracking-tight text-foreground tabular-nums`}\n          >\n            {displayTotal}\n          </motion.div>\n        </div>\n      </div>\n\n      {hasFooter && (\n        <div\n          className={`relative z-10 flex items-center justify-between gap-4 rounded-b-[inherit] border-t border-rule-soft bg-card text-[14px] ${sz.footer}`}\n        >\n          {showDelta && (\n            <div>\n              <motion.span\n                key={`delta-${selected.label}`}\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                transition={{ duration: 0.25 }}\n                className=\"font-medium\"\n                style={{ color: color.text }}\n              >\n                {displayDelta}\n              </motion.span>{\" \"}\n              <span className=\"text-muted-foreground\">{deltaLabel}</span>\n            </div>\n          )}\n          {showStats && (\n            <motion.div\n              key={`stats-${selected.label}`}\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              transition={{ duration: 0.25 }}\n              className=\"ml-auto flex items-center gap-2.5 text-[12px] text-muted-foreground\"\n            >\n              {(resolvedFooterStats.length > 0\n                ? resolvedFooterStats\n                : [\n                    { label: \"peak\", value: stats.peak },\n                    { label: \"low\", value: stats.low },\n                    { label: \"avg\", value: Math.round(stats.avg) },\n                  ]\n              ).map((stat, idx) => (\n                <span key={stat.label} className=\"flex items-center gap-2.5\">\n                  {idx > 0 && <span className=\"opacity-40\">·</span>}\n                  <span>\n                    <span className=\"font-medium text-ink\">\n                      {typeof stat.value === \"number\" ? fmtCompact(stat.value) : stat.value}\n                    </span>{\" \"}\n                    {stat.label}\n                  </span>\n                </span>\n              ))}\n            </motion.div>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n\nexport default MetricCard;\n",
      "type": "registry:component"
    },
    {
      "path": "registry/items/metric-card/metric-chart.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { AnimatePresence, motion, useReducedMotionConfig } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/** One point on a metric line: a value and the label for its x position. */\nexport type SeriesPoint = { value: number; date: string };\n\n/**\n * The card's accent. `go`, `stop` and `neutral` are the up / down / flat\n * signals the card derives from the data itself; `brand` and `lead` let a card\n * opt into a fixed colour instead.\n */\nexport type MetricAccent = \"go\" | \"stop\" | \"neutral\" | \"brand\" | \"lead\";\n\n/** A named series, optionally pinned to its own accent or any CSS colour. */\nexport type MetricSeries = {\n  name: string;\n  data: SeriesPoint[];\n  accent?: MetricAccent;\n  /** Any CSS colour; wins over `accent`. Keep it a token (`var(--hold)`). */\n  color?: string;\n  /** What the footer calls this series' sum when `footerStats=\"series\"`. */\n  footerLabel?: string;\n};\n\nexport type ChartView = \"curve\" | \"bars\";\n\n/** What MetricChart actually draws: a series whose colour is already resolved. */\nexport type ChartSeries = { name: string; data: SeriesPoint[]; color: string };\n\nexport const ACCENTS: Record<MetricAccent, { stroke: string; text: string }> = {\n  go: { stroke: \"var(--go)\", text: \"var(--go)\" },\n  stop: { stroke: \"var(--stop)\", text: \"var(--stop)\" },\n  neutral: { stroke: \"var(--muted-foreground)\", text: \"var(--muted-foreground)\" },\n  // Brand is a mark colour; as text it is pulled toward ink so it still reads.\n  brand: { stroke: \"var(--brand)\", text: \"color-mix(in oklab, var(--brand) 78%, var(--ink))\" },\n  lead: { stroke: \"var(--lead)\", text: \"var(--lead)\" },\n};\n\n/** Cycled through when a card draws more than one series and none set a colour. */\nexport const SERIES_COLORS = [\n  \"var(--lead)\",\n  \"var(--go)\",\n  \"var(--hold)\",\n  \"var(--stop)\",\n  \"var(--faint)\",\n];\n\nexport const EASE = [0.2, 0.7, 0.2, 1] as const;\n\n/** Spelled out rather than `Intl`: runtimes disagree on \"Sep\" vs \"Sept\". */\nexport const SHORT_MONTHS = [\n  \"Jan\",\n  \"Feb\",\n  \"Mar\",\n  \"Apr\",\n  \"May\",\n  \"Jun\",\n  \"Jul\",\n  \"Aug\",\n  \"Sep\",\n  \"Oct\",\n  \"Nov\",\n  \"Dec\",\n] as const;\n\nconst ISO_DAY = /^\\d{4}-\\d{2}-\\d{2}$/;\n\n/** An ISO day becomes \"15 Aug, 2026\"; anything else is already a label. */\nexport function formatPointDate(date: string): string {\n  if (!ISO_DAY.test(date)) return date;\n  const [year, month, day] = date.split(\"-\");\n  return `${day} ${SHORT_MONTHS[Number(month) - 1]}, ${year}`;\n}\n\nexport function formatAxisDate(date: string): string {\n  if (!ISO_DAY.test(date)) return date;\n  const [, month, day] = date.split(\"-\");\n  return `${Number(day)} ${SHORT_MONTHS[Number(month) - 1]}`;\n}\n\n/** 1_240 → \"1.2K\". Used for the headline and the peak/low/avg footer. */\nexport function formatCompact(value: number): string {\n  const abs = Math.abs(value);\n  if (abs >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1).replace(/\\.0$/, \"\")}B`;\n  if (abs >= 1_000_000) return `${(value / 1_000_000).toFixed(1).replace(/\\.0$/, \"\")}M`;\n  if (abs >= 1_000) return `${(value / 1_000).toFixed(1).replace(/\\.0$/, \"\")}K`;\n  return `${Math.round(value)}`;\n}\n\ntype ChartPoint = { x: number; y: number };\n\n/**\n * One monotone cubic per pair of recorded points, so the curve never invents a\n * peak or dip the data does not have.\n */\nfunction monotoneChartPath(pts: ChartPoint[], bandBottom: number): { line: string; fill: string } {\n  if (pts.length === 0) return { line: \"\", fill: \"\" };\n  const coords = (x: number, y: number) => `${x.toFixed(3)} ${y.toFixed(3)}`;\n  if (pts.length === 1) {\n    const line = `M ${coords(3, pts[0].y)} L ${coords(100, pts[0].y)}`;\n    return { line, fill: `${line} L ${coords(100, bandBottom)} L ${coords(3, bandBottom)} Z` };\n  }\n\n  const n = pts.length;\n  const dxs = pts.slice(1).map((point, i) => point.x - pts[i].x);\n  const deltas = dxs.map((dx, i) => (dx === 0 ? 0 : (pts[i + 1].y - pts[i].y) / dx));\n  const tangents: number[] = new Array(n);\n  for (let i = 1; i < n - 1; i++) {\n    const previous = deltas[i - 1];\n    const next = deltas[i];\n    tangents[i] =\n      previous * next <= 0\n        ? 0\n        : (3 * (dxs[i - 1] + dxs[i])) /\n          ((2 * dxs[i] + dxs[i - 1]) / previous + (dxs[i] + 2 * dxs[i - 1]) / next);\n  }\n  tangents[0] = deltas[0];\n  tangents[n - 1] = deltas[n - 2];\n  if (deltas.length > 1) {\n    if (deltas[0] * deltas[1] <= 0) tangents[0] = 0;\n    if (deltas[n - 2] * deltas[n - 3] <= 0) tangents[n - 1] = 0;\n  }\n\n  // Limit the endpoint slopes so each cubic stays within its two real values.\n  for (let i = 0; i < n - 1; i++) {\n    if (deltas[i] === 0) {\n      tangents[i] = 0;\n      tangents[i + 1] = 0;\n    } else {\n      const alpha = tangents[i] / deltas[i];\n      const beta = tangents[i + 1] / deltas[i];\n      const distance = alpha * alpha + beta * beta;\n      if (distance > 9) {\n        const factor = 3 / Math.sqrt(distance);\n        tangents[i] = factor * alpha * deltas[i];\n        tangents[i + 1] = factor * beta * deltas[i];\n      }\n    }\n  }\n\n  let line = `M ${coords(pts[0].x, pts[0].y)}`;\n  for (let i = 0; i < n - 1; i++) {\n    const start = pts[i];\n    const end = pts[i + 1];\n    const third = dxs[i] / 3;\n    line += ` C ${coords(start.x + third, start.y + tangents[i] * third)}, ${coords(end.x - third, end.y - tangents[i + 1] * third)}, ${coords(end.x, end.y)}`;\n  }\n  const fill = `${line} L ${coords(pts[n - 1].x, bandBottom)} L ${coords(pts[0].x, bandBottom)} Z`;\n  return { line, fill };\n}\n\n/*\n * The chart draws into a 0-100 viewBox with preserveAspectRatio=\"none\", so one\n * unit is a percentage of the region in each axis independently. Strokes are\n * non-scaling so that stretch never thickens a line.\n *\n * The band leaves headroom at the top for the card's headline and clearance at\n * the bottom for its footer; the chart sits behind both.\n */\nconst BAND_TOP = 34;\nconst BAND_BOTTOM = 100;\n// Left keeps a small inset so the line isn't glued to the edge; right is 0 so\n// the last point reaches the edge (stopping short reads as a day missing).\nconst X_INSET_LEFT = 3;\nconst X_INSET_RIGHT = 0;\n\nconst SPRING = { type: \"spring\", stiffness: 600, damping: 45 } as const;\n\nexport interface MetricChartProps {\n  series: ChartSeries[];\n  view: ChartView;\n  /** Point the cursor rests on when nothing is hovered. */\n  defaultIndex: number;\n  valueFormatter: (value: number) => string;\n  dateFormatter: (date: string) => string;\n  compactFormatter?: (value: number) => string;\n  bandBottom?: number;\n  bandTop?: number;\n  showAxes?: boolean;\n  showXAxis?: boolean;\n  showYAxis?: boolean;\n  /**\n   * Smooth (monotone spline) vs exact polyline. Default true. Neither mode\n   * morphs between datasets, so a period change never draws phantom peaks.\n   */\n  smooth?: boolean;\n  className?: string;\n}\n\nexport function MetricChart({\n  series,\n  view,\n  defaultIndex,\n  valueFormatter,\n  dateFormatter,\n  compactFormatter = formatCompact,\n  bandBottom: bandBottomProp,\n  bandTop: bandTopProp,\n  showAxes = false,\n  showXAxis: showXAxisProp,\n  showYAxis: showYAxisProp,\n  smooth = true,\n  className,\n}: MetricChartProps) {\n  const rawId = useId().replace(/:/g, \"\");\n  const rootRef = useRef<HTMLDivElement>(null);\n  const [hovered, setHovered] = useState<number | null>(null);\n  const [hasEnteredView, setHasEnteredView] = useState(false);\n  const reduceMotion = useReducedMotionConfig() ?? false;\n\n  useEffect(() => {\n    const node = rootRef.current;\n    if (!node) return;\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        if (entry.isIntersecting) {\n          setHasEnteredView(true);\n          observer.disconnect();\n        }\n      },\n      { threshold: 0.2 },\n    );\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n\n  const showXAxis = showXAxisProp ?? showAxes;\n  const showYAxis = showYAxisProp ?? showAxes;\n  const bandTop = bandTopProp ?? BAND_TOP;\n  const bandBottom = bandBottomProp ?? (showXAxis ? 84 : BAND_BOTTOM);\n  const xInsetRight = showYAxis ? 6 : X_INSET_RIGHT;\n\n  const length = series[0]?.data.length ?? 0;\n  const active = Math.min(Math.max(hovered ?? defaultIndex, 0), Math.max(length - 1, 0));\n\n  // One scale across every series, so two lines on the same card are comparable.\n  const { min, max } = useMemo(() => {\n    const values = series.flatMap((s) => s.data.map((d) => d.value));\n    if (!values.length) return { min: 0, max: 1 };\n    const lo = Math.min(...values);\n    const hi = Math.max(...values);\n    const baseline = showYAxis && lo >= 0 ? 0 : lo;\n    return hi === baseline ? { min: baseline, max: baseline + 1 } : { min: baseline, max: hi };\n  }, [series, showYAxis]);\n\n  const toX = (i: number, len: number) =>\n    len <= 1 ? 50 : X_INSET_LEFT + (i / (len - 1)) * (100 - X_INSET_LEFT - xInsetRight);\n\n  const toY = (value: number) => bandBottom - ((value - min) / (max - min)) * (bandBottom - bandTop);\n\n  const yTicks = useMemo(() => {\n    if (!showYAxis) return [];\n    if (max <= min) return [min];\n    const diff = max - min;\n    const mid = min + diff / 2;\n    const roundMid = Number.isInteger(diff) ? Math.round(mid) : Number(mid.toFixed(1));\n    return Array.from(new Set([max, roundMid, min])).sort((a, b) => b - a);\n  }, [showYAxis, min, max]);\n\n  const xTicks = useMemo(() => {\n    if (!showXAxis || length < 1) return [];\n    const at = (index: number) => ({ index, date: series[0].data[index].date });\n    if (length === 1) return [at(0)];\n    if (length === 2) return [at(0), at(1)];\n    return [at(0), at(Math.floor((length - 1) / 2)), at(length - 1)];\n  }, [showXAxis, length, series]);\n\n  if (!length) return null;\n\n  const activeDate = series[0].data[active]?.date ?? \"\";\n  const cursorX = toX(active, length);\n\n  const handlePointer = (event: React.PointerEvent<HTMLDivElement>) => {\n    const box = event.currentTarget.getBoundingClientRect();\n    if (!box.width || length <= 1) {\n      setHovered(0);\n      return;\n    }\n    const rawX = ((event.clientX - box.left) / box.width) * 100;\n    const fractionalIndex = ((rawX - X_INSET_LEFT) / (100 - X_INSET_LEFT - xInsetRight)) * (length - 1);\n    setHovered(Math.min(length - 1, Math.max(0, Math.round(fractionalIndex))));\n  };\n\n  return (\n    <div\n      ref={rootRef}\n      className={cn(\"relative h-full w-full touch-none select-none overflow-visible\", className)}\n      onPointerLeave={() => setHovered(null)}\n      onPointerDown={handlePointer}\n      onPointerMove={handlePointer}\n    >\n      {/* Clips the plot to the region, inheriting the card's right-hand radius. */}\n      <div className=\"absolute inset-0 overflow-hidden rounded-r-[inherit]\">\n        <svg className=\"h-full w-full\" viewBox=\"0 0 100 100\" preserveAspectRatio=\"none\" aria-hidden=\"true\">\n          <defs>\n            {series.map((s, i) => (\n              <linearGradient key={s.name} id={`${rawId}-fill-${i}`} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n                <stop offset=\"0%\" stopColor={s.color} stopOpacity={0.28} />\n                <stop offset=\"60%\" stopColor={s.color} stopOpacity={0.08} />\n                <stop offset=\"100%\" stopColor={s.color} stopOpacity={0} />\n              </linearGradient>\n            ))}\n          </defs>\n\n          {/* Cursor guide line with spring tracking */}\n          <motion.line\n            initial={false}\n            animate={{ x1: cursorX, x2: cursorX, opacity: hovered !== null ? 0.45 : 0 }}\n            transition={{ x1: SPRING, x2: SPRING, opacity: { duration: 0.15 } }}\n            y1={bandTop - 6}\n            y2={bandBottom}\n            stroke=\"currentColor\"\n            strokeWidth={1}\n            strokeDasharray=\"3 3\"\n            vectorEffect=\"non-scaling-stroke\"\n            className=\"text-foreground\"\n          />\n\n          {showYAxis &&\n            yTicks.map((tick) => (\n              <line\n                key={`ygrid-${tick}`}\n                x1={0}\n                x2={100}\n                y1={toY(tick)}\n                y2={toY(tick)}\n                stroke=\"currentColor\"\n                strokeDasharray=\"2 3\"\n                strokeWidth={1}\n                vectorEffect=\"non-scaling-stroke\"\n                className=\"text-ink/[0.06]\"\n              />\n            ))}\n\n          {showXAxis && (\n            <line\n              x1={0}\n              x2={100}\n              y1={bandBottom}\n              y2={bandBottom}\n              stroke=\"currentColor\"\n              strokeWidth={1}\n              vectorEffect=\"non-scaling-stroke\"\n              className=\"text-ink/[0.08]\"\n            />\n          )}\n\n          {series.map((s, seriesIndex) => {\n            const pts = s.data.map((d, i) => ({ x: toX(i, s.data.length), y: toY(d.value) }));\n\n            if (view === \"bars\") {\n              const slot = (100 - X_INSET_LEFT - xInsetRight) / Math.max(s.data.length, 1);\n              const width = Math.max(slot * (series.length > 1 ? 0.34 : 0.5), 0.6);\n              const offset = (seriesIndex - (series.length - 1) / 2) * width;\n              return (\n                <g key={s.name}>\n                  {pts.map((p, i) => {\n                    // The last point sits on the right edge; pull its bar inward\n                    // so it doesn't spill past the card border.\n                    const barXShift = i === s.data.length - 1 ? -width / 2 : 0;\n                    return (\n                      <motion.rect\n                        key={s.data[i].date}\n                        x={p.x + offset - width / 2 + barXShift}\n                        width={width}\n                        rx={0.5}\n                        initial={reduceMotion ? false : { y: bandBottom, height: 0, opacity: 0 }}\n                        animate={{\n                          y: p.y,\n                          height: Math.max(bandBottom - p.y, 0.5),\n                          opacity:\n                            hasEnteredView || reduceMotion\n                              ? hovered !== null\n                                ? i === active\n                                  ? 1\n                                  : 0.42\n                                : 0.85\n                              : 0,\n                        }}\n                        transition={\n                          reduceMotion ? { duration: 0 } : { type: \"spring\", stiffness: 380, damping: 28 }\n                        }\n                        fill={s.color}\n                      />\n                    );\n                  })}\n                </g>\n              );\n            }\n\n            if (pts.length === 0) return null;\n\n            // Exact mode: straight segments through every point, no smoothing\n            // and no morph, so a new window snaps to its true shape.\n            if (!smooth) {\n              if (pts.length === 1) return <g key={s.name} />;\n              const line = pts\n                .map((p, i) => `${i === 0 ? \"M\" : \"L\"} ${p.x.toFixed(3)} ${p.y.toFixed(3)}`)\n                .join(\" \");\n              const fill = `${line} L ${pts[pts.length - 1].x.toFixed(3)} ${bandBottom.toFixed(3)} L ${pts[0].x.toFixed(3)} ${bandBottom.toFixed(3)} Z`;\n              return (\n                <g key={s.name}>\n                  <path d={fill} fill={`url(#${rawId}-fill-${seriesIndex})`} />\n                  <path\n                    d={line}\n                    fill=\"none\"\n                    stroke={s.color}\n                    strokeWidth={2.25}\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    vectorEffect=\"non-scaling-stroke\"\n                  />\n                </g>\n              );\n            }\n\n            const { line, fill } = monotoneChartPath(pts, bandBottom);\n            return (\n              <g key={`${s.name}:${s.data[0]?.date}:${s.data[s.data.length - 1]?.date}:${line}`}>\n                {/* Dash-based drawing breaks under non-uniform scaling with\n                    non-scaling-stroke, so a clip reveals line and fill together. */}\n                <defs>\n                  <clipPath id={`${rawId}-reveal-${seriesIndex}`} clipPathUnits=\"userSpaceOnUse\">\n                    <motion.rect\n                      x={0}\n                      y={0}\n                      height={100}\n                      width={100}\n                      initial={reduceMotion ? false : { width: 0 }}\n                      animate={{ width: hasEnteredView || reduceMotion ? 100 : 0 }}\n                      transition={{ duration: reduceMotion ? 0 : 0.65, ease: EASE }}\n                    />\n                  </clipPath>\n                </defs>\n                <g clipPath={`url(#${rawId}-reveal-${seriesIndex})`}>\n                  <path d={fill} fill={`url(#${rawId}-fill-${seriesIndex})`} />\n                  <path\n                    d={line}\n                    fill=\"none\"\n                    stroke={s.color}\n                    strokeWidth={2.25}\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    vectorEffect=\"non-scaling-stroke\"\n                  />\n                </g>\n              </g>\n            );\n          })}\n        </svg>\n      </div>\n\n      {showYAxis &&\n        yTicks.map((tick) => (\n          <div\n            key={`ytick-${tick}`}\n            className=\"pointer-events-none absolute right-1.5 z-10 -translate-y-1/2 select-none\"\n            style={{ top: `${toY(tick)}%` }}\n          >\n            <span className=\"font-body text-[11px] font-medium tabular-nums text-dim\">\n              {compactFormatter(tick)}\n            </span>\n          </div>\n        ))}\n\n      {showXAxis && xTicks.length > 0 && (\n        <div className=\"pointer-events-none absolute inset-x-0 bottom-1 z-10 select-none font-body text-[11px] font-medium text-dim\">\n          {xTicks.map((tick, i) => {\n            const isFirst = i === 0;\n            const isLast = i === xTicks.length - 1;\n            return (\n              <span\n                key={`xtick-${tick.date}-${i}`}\n                className=\"absolute whitespace-nowrap\"\n                style={{\n                  left: `${toX(tick.index, length)}%`,\n                  transform: isFirst ? \"translateX(0)\" : isLast ? \"translateX(-100%)\" : \"translateX(-50%)\",\n                }}\n              >\n                {formatAxisDate(tick.date)}\n              </span>\n            );\n          })}\n        </div>\n      )}\n\n      {/* Point markers, outside the clipped wrapper so the end dot can sit on the border. */}\n      {view === \"curve\" &&\n        series.map((s) => {\n          const point = s.data[Math.min(active, s.data.length - 1)];\n          if (!point) return null;\n          const targetX = toX(Math.min(active, s.data.length - 1), s.data.length);\n          const targetY = toY(point.value);\n          // A lone point has no line to read, so its dot stays visible.\n          const alone = s.data.length <= 1;\n          // Nudge the right-edge dot inward via the standalone `translate`\n          // property; Motion owns `transform`.\n          const edgeDotNudge = targetX >= 100 ? \"8px\" : null;\n          return (\n            <motion.div\n              key={s.name}\n              className=\"pointer-events-none absolute -translate-x-1/2 -translate-y-1/2\"\n              style={edgeDotNudge ? { translate: `calc(-50% + ${edgeDotNudge}) -50%` } : undefined}\n              initial={false}\n              animate={{\n                left: `${targetX}%`,\n                top: `${targetY}%`,\n                scale: hovered !== null ? 1.15 : alone ? 1 : 0.7,\n                opacity: hovered !== null || alone ? 1 : 0,\n              }}\n              transition={{\n                left: SPRING,\n                top: SPRING,\n                scale: { duration: 0.15 },\n                opacity: { duration: 0.15 },\n              }}\n            >\n              <span\n                className=\"relative block h-3 w-3 rounded-full border-2 border-card shadow-sm\"\n                style={{ background: s.color }}\n              />\n            </motion.div>\n          );\n        })}\n\n      {/* Tooltip: a glass card tracking the cursor on a spring. */}\n      <AnimatePresence>\n        {hovered !== null && (\n          <motion.div\n            className=\"pointer-events-none absolute z-50\"\n            initial={{ opacity: 0, scale: 0.95 }}\n            animate={{ left: `${cursorX}%`, top: `${BAND_TOP}%`, opacity: 1, scale: 1 }}\n            exit={{ opacity: 0, scale: 0.95 }}\n            transition={{\n              left: SPRING,\n              top: SPRING,\n              opacity: { duration: 0.15 },\n              scale: { duration: 0.15 },\n            }}\n            // Flip with the standalone `translate` property, not `transform`:\n            // Motion rewrites `transform` every frame while animating `scale`.\n            style={{ translate: cursorX > 50 ? \"calc(-100% - 12px) -50%\" : \"12px -50%\" }}\n          >\n            <div className=\"relative min-w-[130px] rounded-xl border border-rule bg-popover/95 px-3.5 py-2.5 shadow-lg backdrop-blur-md\">\n              <p className=\"whitespace-nowrap text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground/80\">\n                {dateFormatter(activeDate)}\n              </p>\n              <div className=\"mt-1 space-y-1\">\n                {series.map((s) => {\n                  const val = s.data[Math.min(active, s.data.length - 1)]?.value ?? 0;\n                  return (\n                    <div\n                      key={s.name}\n                      className=\"flex items-center justify-between gap-3 whitespace-nowrap text-[13px] font-semibold tabular-nums text-foreground\"\n                    >\n                      <div className=\"flex items-center gap-1.5\">\n                        <span\n                          className=\"h-2 w-2 rounded-full ring-2 ring-card\"\n                          style={{ background: s.color, boxShadow: `0 0 6px ${s.color}` }}\n                        />\n                        {series.length > 1 && (\n                          <span className=\"text-[12px] font-medium text-muted-foreground\">{s.name}</span>\n                        )}\n                      </div>\n                      <span className=\"font-bold tracking-tight\">{valueFormatter(val)}</span>\n                    </div>\n                  );\n                })}\n              </div>\n            </div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/items/metric-card/metric-controls.tsx",
      "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Activity, BarChart3, CalendarRange, Check, ChevronDown } from \"lucide-react\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  DateRangeCalendar,\n  isCompleteRange,\n  keepInViewport,\n  type RangeSelection,\n} from \"@/components/date-range-calendar\";\nimport { EASE, SHORT_MONTHS, type ChartView } from \"./metric-chart\";\n\n/**\n * A selectable window over the series. Two shapes:\n * - `points`: how many trailing points to keep; omit for \"everything\".\n * - `from`/`to`: inclusive ISO days (YYYY-MM-DD). When present they win over `points`.\n */\nexport type PeriodOption = { label: string; points?: number; from?: string; to?: string };\n\n/**\n * The custom-range editor's own width, fixed on purpose: the popover shrink-wraps\n * its content, and animating `width: 0 → auto` needs a stable target.\n */\nconst RANGE_EDITOR_WIDTH = \"16.5rem\";\n\n/* Popover motion. Opening uses EASE (front-loaded, answers the press at once);\n * closing uses its mirror, COLLAPSE_EASE, and runs a little longer, so the box\n * leaves gently instead of lurching away and crawling. */\nconst EXPAND = { duration: 0.42, ease: EASE } as const;\nconst COLLAPSE_EASE = [0.8, 0, 0.3, 0.8] as const;\nconst COLLAPSE = { duration: 0.52, ease: COLLAPSE_EASE } as const;\n/** The calendar fades in behind the box, and out ahead of it. */\nconst FADE_IN = { duration: 0.28, delay: 0.08 } as const;\nconst FADE_OUT = { duration: 0.3 } as const;\nconst POPOVER_IN = { duration: 0.16, ease: EASE } as const;\nconst POPOVER_OUT = { duration: 0.26, ease: COLLAPSE_EASE } as const;\n\nconst FOCUS = \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand\";\n\n/** \"12 Aug – 25 Aug\", appending the year whenever either end isn't this year. */\nfunction formatRangeLabel(from: string, to: string): string {\n  const currentYear = new Date().getUTCFullYear();\n  const part = (iso: string) => {\n    const d = new Date(`${iso}T00:00:00Z`);\n    const label = `${d.getUTCDate()} ${SHORT_MONTHS[d.getUTCMonth()]}`;\n    return d.getUTCFullYear() === currentYear ? label : `${label} ${d.getUTCFullYear()}`;\n  };\n  return `${part(from)} – ${part(to)}`;\n}\n\nfunction CustomRangeEditor({\n  initial,\n  canReset,\n  min,\n  max,\n  onApply,\n  onReset,\n}: {\n  initial: { from: string; to: string } | null;\n  canReset: boolean;\n  min?: string | null;\n  max?: string | null;\n  onApply: (from: string, to: string) => void;\n  onReset: () => void;\n}) {\n  const [selection, setSelection] = useState<RangeSelection>(() => ({\n    from: initial?.from ?? null,\n    to: initial?.to ?? null,\n  }));\n  const valid = isCompleteRange(selection);\n\n  return (\n    <form\n      className=\"mt-1 space-y-2 rounded-xl bg-paper p-2\"\n      style={{ width: RANGE_EDITOR_WIDTH }}\n      onSubmit={(event) => {\n        event.preventDefault();\n        if (isCompleteRange(selection)) onApply(selection.from, selection.to);\n      }}\n    >\n      <DateRangeCalendar value={selection} onChange={setSelection} min={min} max={max} />\n      <div className=\"flex items-center gap-1.5\">\n        <button\n          type=\"submit\"\n          disabled={!valid}\n          className={cn(\n            \"flex-1 rounded-lg bg-brand px-3 py-1.5 text-[12px] font-bold text-on-solid transition-opacity hover:opacity-90 disabled:pointer-events-none disabled:opacity-35\",\n            FOCUS,\n          )}\n        >\n          Apply range\n        </button>\n        {canReset && (\n          <button\n            type=\"button\"\n            aria-label=\"Reset to default period\"\n            onClick={onReset}\n            className={cn(\n              \"rounded-lg px-3 py-1.5 text-[12px] font-bold text-muted-foreground transition-colors hover:bg-paper-sunk hover:text-foreground\",\n              FOCUS,\n            )}\n          >\n            Reset\n          </button>\n        )}\n      </div>\n    </form>\n  );\n}\n\n/*\n * Both controls sit inside the card's `pointer-events-none` content layer\n * (which lets the chart underneath take the hover), so each interactive\n * element opts back in with `pointer-events-auto`.\n */\n\nexport function ViewToggle({\n  value,\n  onChange,\n  layoutId = \"view-toggle-pill\",\n}: {\n  value: ChartView;\n  onChange: (view: ChartView) => void;\n  /** The moving pill's shared-layout id. Give each card on a page its own. */\n  layoutId?: string;\n}) {\n  const item = (view: ChartView, Icon: typeof Activity, label: string) => {\n    const isSelected = value === view;\n    return (\n      <button\n        type=\"button\"\n        aria-label={label}\n        aria-pressed={isSelected}\n        onClick={() => onChange(view)}\n        className={cn(\n          \"pointer-events-auto relative z-10 rounded-full p-1.5 transition-colors\",\n          FOCUS,\n          isSelected ? \"font-semibold text-foreground\" : \"text-faint hover:text-ink\",\n        )}\n      >\n        {isSelected && (\n          <motion.div\n            layoutId={layoutId}\n            className=\"absolute inset-0 rounded-full bg-card shadow-sm\"\n            transition={{ type: \"spring\", stiffness: 450, damping: 30 }}\n          />\n        )}\n        <span className=\"relative z-10 block\">\n          <Icon size={14} strokeWidth={2.5} />\n        </span>\n      </button>\n    );\n  };\n\n  return (\n    <div className=\"flex items-center gap-0.5 rounded-full bg-paper p-0.5 backdrop-blur-sm\">\n      {item(\"curve\", Activity, \"Line view\")}\n      {item(\"bars\", BarChart3, \"Bar view\")}\n    </div>\n  );\n}\n\nexport function PeriodSelect({\n  value,\n  options,\n  onChange,\n  accentText,\n  allowCustomRange = false,\n  defaultOption,\n  rangeMin,\n  rangeMax,\n}: {\n  value: string;\n  options: PeriodOption[];\n  onChange: (option: PeriodOption) => void;\n  accentText: string;\n  allowCustomRange?: boolean;\n  /** Where \"Reset\" sends the selection back to. */\n  defaultOption?: PeriodOption;\n  /** Inclusive ISO-day bounds the calendar may select within. */\n  rangeMin?: string | null;\n  rangeMax?: string | null;\n}) {\n  const [open, setOpen] = useState(false);\n  const [customOpen, setCustomOpen] = useState(false);\n  const [appliedCustom, setAppliedCustom] = useState<{ label: string; from: string; to: string } | null>(null);\n  const isCustomSelected = appliedCustom !== null && value === appliedCustom.label;\n\n  const optionClass = (selected: boolean) =>\n    cn(\n      \"flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2 text-left text-[13px] font-medium transition-colors\",\n      selected ? \"bg-paper\" : \"hover:bg-paper\",\n    );\n\n  return (\n    // Below `sm` the wrapper takes the full row, so the right-anchored panel\n    // drops from the row's right edge instead of spilling off-screen.\n    <div\n      className={cn(\"relative max-sm:w-full\", open && \"z-50\")}\n      onBlur={(event) => {\n        if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setOpen(false);\n      }}\n      onKeyDown={(event) => {\n        if (event.key === \"Escape\") setOpen(false);\n      }}\n    >\n      <button\n        type=\"button\"\n        aria-haspopup=\"listbox\"\n        aria-expanded={open}\n        onClick={() => setOpen((previous) => !previous)}\n        className={cn(\n          \"pointer-events-auto flex items-center gap-1.5 rounded-full bg-paper px-2.5 py-1 text-[13px] font-medium text-muted-foreground transition-all hover:text-foreground max-sm:inline-flex\",\n          FOCUS,\n        )}\n      >\n        <span>{value}</span>\n        <motion.div animate={{ rotate: open ? 180 : 0 }} transition={{ duration: 0.2 }}>\n          <ChevronDown size={14} strokeWidth={2.5} />\n        </motion.div>\n      </button>\n\n      <AnimatePresence>\n        {open && (\n          <motion.ul\n            role=\"listbox\"\n            initial={{ opacity: 0, scale: 0.95, y: -4 }}\n            animate={{ opacity: 1, scale: 1, y: 0 }}\n            exit={{ opacity: 0, scale: 0.95, y: -4, transition: POPOVER_OUT }}\n            transition={POPOVER_IN}\n            // `w-max` lets the popover follow the editor's animated width frame\n            // by frame; the max-width keeps it on a narrow phone.\n            ref={keepInViewport}\n            className=\"pointer-events-auto absolute right-0 top-full z-50 mt-1.5 w-max min-w-[10.5rem] max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-rule bg-popover/95 p-1.5 shadow-xl backdrop-blur-md\"\n          >\n            {options.map((option) => {\n              const isSelected = option.label === value;\n              return (\n                <li key={option.label}>\n                  <button\n                    type=\"button\"\n                    role=\"option\"\n                    aria-selected={isSelected}\n                    onClick={() => {\n                      onChange(option);\n                      setCustomOpen(false);\n                      setOpen(false);\n                    }}\n                    className={optionClass(isSelected)}\n                    style={isSelected ? { color: accentText } : undefined}\n                  >\n                    <span>{option.label}</span>\n                    {isSelected && (\n                      <motion.span\n                        initial={{ scale: 0 }}\n                        animate={{ scale: 1 }}\n                        transition={{ type: \"spring\", stiffness: 500, damping: 25 }}\n                      >\n                        <Check size={14} strokeWidth={3} />\n                      </motion.span>\n                    )}\n                  </button>\n                </li>\n              );\n            })}\n\n            {allowCustomRange && (\n              <>\n                <li aria-hidden=\"true\" className=\"mx-2 my-1 border-t border-rule\" />\n                <li>\n                  <button\n                    type=\"button\"\n                    role=\"option\"\n                    aria-selected={isCustomSelected}\n                    onClick={() => setCustomOpen((previous) => !previous)}\n                    className={optionClass(isCustomSelected)}\n                    style={isCustomSelected ? { color: accentText } : undefined}\n                  >\n                    <span>{appliedCustom?.label ?? \"Custom range\"}</span>\n                    <motion.span\n                      initial={false}\n                      animate={{ rotate: customOpen ? 90 : 0 }}\n                      transition={{ duration: 0.2 }}\n                      className=\"shrink-0\"\n                    >\n                      {isCustomSelected ? (\n                        <Check size={14} strokeWidth={3} />\n                      ) : (\n                        <CalendarRange size={14} strokeWidth={2.5} />\n                      )}\n                    </motion.span>\n                  </button>\n                  <AnimatePresence initial={false}>\n                    {customOpen && (\n                      <motion.div\n                        // Width and height on one curve, so the shrink-wrapped\n                        // popover widens with the editor as a single motion.\n                        initial={{ opacity: 0, width: 0, height: 0 }}\n                        animate={{ opacity: 1, width: RANGE_EDITOR_WIDTH, height: \"auto\" }}\n                        exit={{\n                          opacity: 0,\n                          width: 0,\n                          height: 0,\n                          transition: {\n                            width: COLLAPSE,\n                            height: COLLAPSE,\n                            opacity: { ...FADE_OUT, ease: COLLAPSE_EASE },\n                          },\n                        }}\n                        transition={{ width: EXPAND, height: EXPAND, opacity: { ...FADE_IN, ease: EASE } }}\n                        className=\"overflow-hidden\"\n                      >\n                        <CustomRangeEditor\n                          initial={appliedCustom}\n                          canReset={isCustomSelected}\n                          min={rangeMin}\n                          max={rangeMax}\n                          onReset={() => {\n                            setAppliedCustom(null);\n                            setCustomOpen(false);\n                            setOpen(false);\n                            if (defaultOption) onChange(defaultOption);\n                          }}\n                          onApply={(from, to) => {\n                            const label = formatRangeLabel(from, to);\n                            setAppliedCustom({ label, from, to });\n                            onChange({ label, from, to });\n                            setCustomOpen(false);\n                            setOpen(false);\n                          }}\n                        />\n                      </motion.div>\n                    )}\n                  </AnimatePresence>\n                </li>\n              </>\n            )}\n          </motion.ul>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "kind": "widget",
    "whenToUse": "Use for one headline number that moves over time (a total, a daily count) where the reader wants the figure first and the shape second, with hover to read any day. Not for comparing many categories or for a table of figures; use a bar chart or a list for those.",
    "origin": {
      "project": "180Connect",
      "path": "src/components/ui/progress-metric-card.tsx"
    },
    "credits": []
  },
  "type": "registry:component"
}
