{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "status-gauge-card",
  "title": "Status Gauge Card",
  "description": "A status card with a headline word, a metric, an avatar stack, and a segmented semicircular gauge.",
  "registryDependencies": [
    "card",
    "button",
    "tooltip"
  ],
  "files": [
    {
      "path": "registry/dashboardcn/blocks/status-gauge-card.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowUpRight } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { type NumberFormat } from \"@/registry/dashboardcn/lib/format\"\nimport { Button } from \"@/components/ui/button\"\nimport { Card, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\"\nimport { RadialGauge } from \"@/registry/dashboardcn/ui/radial-gauge\"\nimport { MetricValue } from \"@/registry/dashboardcn/ui/metric-value\"\n\nexport interface StatusGaugePerson {\n  name: string\n  /** Avatar image. Falls back to the first letter of the name. */\n  src?: string\n}\n\nexport interface StatusGaugeCardProps extends React.ComponentProps<typeof Card> {\n  title: string\n  /** Short status word, e.g. \"Stable\" or \"In review\". */\n  status: string\n  /** Metric name shown before the value, e.g. \"Server uptime\". */\n  metricLabel: string\n  value: number | string\n  format?: NumberFormat\n  currency?: string\n  /** Gauge fill from 0 to 100. Defaults to value when value is a number. */\n  progress?: number\n  /** Any CSS color for the gauge. */\n  color?: string\n  /** Number of ticks in the gauge. 0 draws a continuous arc. */\n  segments?: number\n  /** Space between ticks in degrees. Larger gaps make thinner ticks. */\n  segmentGap?: number\n  /** Length of each tick in pixels. */\n  thickness?: number\n  /** Gauge diameter in pixels. */\n  gaugeSize?: number\n  /** Avatars shown under the metric. */\n  people?: StatusGaugePerson[]\n  /** Text next to the avatars, e.g. \"6 reviewers\". */\n  peopleLabel?: string\n  /** Header button. On narrow cards only the icon shows. */\n  action?: {\n    label: string\n    href?: string\n    onClick?: () => void\n    icon?: React.ReactNode\n  }\n}\n\n/** A status card with a headline word, a metric, an avatar stack, and a segmented gauge. */\nfunction StatusGaugeCard({\n  title,\n  status,\n  metricLabel,\n  value,\n  format = \"percent\",\n  currency,\n  progress,\n  color = \"var(--color-emerald-500)\",\n  segments = 40,\n  segmentGap = 2.5,\n  thickness = 14,\n  gaugeSize = 200,\n  people = [],\n  peopleLabel,\n  action,\n  className,\n  ...props\n}: StatusGaugeCardProps) {\n  const numeric = typeof value === \"number\"\n  const gauge =\n    progress ?? (numeric ? (format === \"percent\" ? value * 100 : value) : 0)\n\n  return (\n    <Card\n      data-slot=\"status-gauge-card\"\n      className={cn(\"@container/card gap-0 py-0\", className)}\n      {...props}\n    >\n      <CardHeader className=\"flex items-center justify-between gap-3 border-b py-3 [.border-b]:pb-3\">\n        <CardTitle className=\"truncate text-sm\">{title}</CardTitle>\n        {action ? (\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            asChild={Boolean(action.href)}\n            onClick={action.onClick}\n            className=\"shrink-0\"\n          >\n            {action.href ? (\n              <a href={action.href}>\n                <span className=\"@max-xs/card:sr-only\">{action.label}</span>\n                <span className=\"@xs/card:hidden [&>svg]:size-4\">\n                  {action.icon ?? <ArrowUpRight />}\n                </span>\n              </a>\n            ) : (\n              <>\n                <span className=\"@max-xs/card:sr-only\">{action.label}</span>\n                <span className=\"@xs/card:hidden [&>svg]:size-4\">\n                  {action.icon ?? <ArrowUpRight />}\n                </span>\n              </>\n            )}\n          </Button>\n        ) : null}\n      </CardHeader>\n      <CardContent className=\"py-5\">\n        <div className=\"flex flex-col gap-6 @sm/card:flex-row @sm/card:items-end @sm/card:justify-between\">\n          <div className=\"flex min-w-0 flex-col gap-1\">\n            <span className=\"text-lg font-semibold\">{status}</span>\n            <span className=\"text-muted-foreground text-sm\">\n              {metricLabel}:{\" \"}\n              <MetricValue\n                value={value}\n                format={format}\n                currency={currency}\n                className=\"text-foreground font-semibold\"\n              />\n            </span>\n            {people.length || peopleLabel ? (\n              <div className=\"mt-4 flex items-center gap-2\">\n                {people.length ? (\n                  <div\n                    className=\"flex -space-x-2\"\n                    aria-label={people.map((p) => p.name).join(\", \")}\n                  >\n                    {people.map((person) => (\n                      <span\n                        key={person.name}\n                        title={person.name}\n                        className=\"bg-muted ring-card text-muted-foreground flex size-7 items-center justify-center overflow-hidden rounded-full text-xs font-medium ring-2\"\n                      >\n                        {person.src ? (\n                          // eslint-disable-next-line @next/next/no-img-element\n                          <img\n                            src={person.src}\n                            alt={person.name}\n                            className=\"size-full object-cover\"\n                          />\n                        ) : (\n                          person.name.charAt(0).toUpperCase()\n                        )}\n                      </span>\n                    ))}\n                  </div>\n                ) : null}\n                {peopleLabel ? (\n                  <span className=\"text-muted-foreground text-sm whitespace-nowrap\">\n                    {peopleLabel}\n                  </span>\n                ) : null}\n              </div>\n            ) : null}\n          </div>\n          <RadialGauge\n            value={gauge}\n            segments={segments}\n            gap={segmentGap}\n            size={gaugeSize}\n            thickness={thickness}\n            color={color}\n            className=\"self-center @sm/card:self-end\"\n          />\n        </div>\n      </CardContent>\n    </Card>\n  )\n}\n\nexport { StatusGaugeCard }\n",
      "type": "registry:component"
    },
    {
      "path": "registry/dashboardcn/ui/radial-gauge.tsx",
      "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface RadialGaugeProps extends React.ComponentProps<\"div\"> {\n  value: number\n  min?: number\n  max?: number\n  /** Diameter in pixels. */\n  size?: number\n  /** Stroke width in pixels. */\n  thickness?: number\n  /** Number of arc segments. 0 draws a continuous arc. */\n  segments?: number\n  /** Gap between segments in degrees. */\n  gap?: number\n  /** Angle where the arc starts, in degrees clockwise from 12 o'clock. */\n  startAngle?: number\n  /** Total sweep of the arc in degrees. 180 is a semicircle, 360 a ring. */\n  sweep?: number\n  /** Any CSS color. Defaults to chart-1. */\n  color?: string\n  trackColor?: string\n  /** Reveal filled segments in sequence when the gauge mounts. */\n  animate?: boolean\n  /** Total reveal time in milliseconds. */\n  animationDuration?: number\n  /** Content rendered in the middle of the gauge. */\n  children?: React.ReactNode\n}\n\n// Round so server and client render identical path strings.\nconst round = (n: number) => Math.round(n * 1000) / 1000\n\nfunction polar(cx: number, cy: number, r: number, angleDeg: number) {\n  const rad = ((angleDeg - 90) * Math.PI) / 180\n  return { x: round(cx + r * Math.cos(rad)), y: round(cy + r * Math.sin(rad)) }\n}\n\nfunction arcPath(cx: number, cy: number, r: number, from: number, to: number) {\n  const start = polar(cx, cy, r, from)\n  const end = polar(cx, cy, r, to)\n  const largeArc = to - from > 180 ? 1 : 0\n  return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y}`\n}\n\nfunction RadialGauge({\n  value,\n  min = 0,\n  max = 100,\n  size = 160,\n  thickness = 10,\n  segments = 0,\n  gap = 2,\n  startAngle,\n  sweep = 180,\n  color = \"var(--chart-1)\",\n  trackColor = \"var(--muted)\",\n  animate = false,\n  animationDuration = 700,\n  className,\n  children,\n  ...props\n}: RadialGaugeProps) {\n  const fraction = Math.min(1, Math.max(0, (value - min) / (max - min || 1)))\n  const start = startAngle ?? (sweep >= 360 ? 0 : -sweep / 2)\n  const r = size / 2 - thickness / 2\n  const cx = size / 2\n  const cy = size / 2\n  // Trim the box to the arc's vertical extent so a semicircle does not\n  // reserve a full circle of space.\n  const angles = Array.from({ length: 64 }, (_, i) => start + (sweep * i) / 63)\n  const ys = angles.map((a) => polar(cx, cy, r, a).y)\n  const top = Math.max(0, Math.min(...ys) - thickness / 2)\n  const bottom = Math.min(size, Math.max(...ys) + thickness / 2)\n  const height = bottom - top\n\n  const arcs: { from: number; to: number; filled: boolean }[] = []\n  if (segments > 0) {\n    const step = sweep / segments\n    for (let i = 0; i < segments; i++) {\n      const from = start + i * step + gap / 2\n      const to = start + (i + 1) * step - gap / 2\n      const mid = (i + 0.5) / segments\n      arcs.push({ from, to, filled: mid <= fraction })\n    }\n  }\n\n  return (\n    <div\n      data-slot=\"radial-gauge\"\n      role=\"meter\"\n      aria-valuemin={min}\n      aria-valuemax={max}\n      aria-valuenow={value}\n      className={cn(\"relative inline-flex shrink-0\", className)}\n      style={{ width: size, height }}\n      {...props}\n    >\n      <svg\n        width={size}\n        height={height}\n        viewBox={`0 ${top} ${size} ${height}`}\n        className=\"absolute inset-0\"\n        aria-hidden=\"true\"\n      >\n        {segments > 0 ? (\n          arcs.map((arc, index) => {\n            const path = arcPath(cx, cy, r, arc.from, arc.to)\n            const filledCount = Math.max(1, Math.round(fraction * segments))\n            const delay = (index / filledCount) * animationDuration * 0.8\n\n            return (\n              <g key={index}>\n                <path\n                  d={path}\n                  fill=\"none\"\n                  stroke={trackColor}\n                  strokeWidth={thickness}\n                  strokeLinecap=\"butt\"\n                />\n                {arc.filled ? (\n                  <path\n                    d={path}\n                    fill=\"none\"\n                    stroke={color}\n                    strokeWidth={thickness}\n                    strokeLinecap=\"butt\"\n                    className={cn(\n                      animate && \"motion-safe:animate-in motion-safe:fade-in\"\n                    )}\n                    style={\n                      animate\n                        ? {\n                            animationDelay: `${delay}ms`,\n                            animationDuration: `${Math.max(120, animationDuration * 0.25)}ms`,\n                            animationFillMode: \"both\",\n                          }\n                        : undefined\n                    }\n                  />\n                ) : null}\n              </g>\n            )\n          })\n        ) : (\n          <>\n            <path\n              d={arcPath(cx, cy, r, start, start + sweep - (sweep >= 360 ? 0.01 : 0))}\n              fill=\"none\"\n              stroke={trackColor}\n              strokeWidth={thickness}\n              strokeLinecap={sweep >= 360 ? \"butt\" : \"round\"}\n            />\n            {fraction > 0 ? (\n              <path\n                d={arcPath(cx, cy, r, start, start + sweep * fraction)}\n                fill=\"none\"\n                stroke={color}\n                strokeWidth={thickness}\n                strokeLinecap=\"round\"\n                className={cn(\n                  \"transition-[d] duration-500\",\n                  animate && \"motion-safe:animate-in motion-safe:fade-in\"\n                )}\n                style={\n                  animate\n                    ? { animationDuration: `${animationDuration}ms` }\n                    : undefined\n                }\n              />\n            ) : null}\n          </>\n        )}\n      </svg>\n      {children ? (\n        <div\n          className={cn(\n            \"absolute inset-x-0 flex flex-col items-center justify-center text-center\",\n            sweep > 200 ? \"inset-y-0\" : \"bottom-0\"\n          )}\n        >\n          {children}\n        </div>\n      ) : null}\n    </div>\n  )\n}\n\nexport { RadialGauge }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/dashboardcn/ui/metric-value.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { formatNumber, type NumberFormat } from \"@/registry/dashboardcn/lib/format\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\"\n\n/** Values at or above this are abbreviated by default. */\nexport const DEFAULT_COMPACT_FROM = 100_000\n\nexport interface MetricValueProps extends Omit<React.ComponentProps<\"span\">, \"children\"> {\n  /** Strings render as-is. */\n  value: number | string\n  format?: NumberFormat\n  currency?: string\n  maximumFractionDigits?: number\n  /** Values at or above this are abbreviated (e.g. $158K) with the full value in a tooltip. Set to Infinity to always show the full value. */\n  compactFrom?: number\n  /** Text after the number, e.g. a unit. */\n  suffix?: React.ReactNode\n}\n\n/** A formatted number. Large values are abbreviated and reveal the full value on hover. */\nfunction MetricValue({\n  value,\n  format,\n  currency,\n  maximumFractionDigits,\n  compactFrom = DEFAULT_COMPACT_FROM,\n  suffix,\n  className,\n  ...props\n}: MetricValueProps) {\n  const classes = cn(\"tabular-nums\", className)\n\n  if (typeof value !== \"number\") {\n    return (\n      <span data-slot=\"metric-value\" className={classes} {...props}>\n        {value}\n        {suffix}\n      </span>\n    )\n  }\n\n  const full = formatNumber(value, { format, currency, maximumFractionDigits })\n  const abbreviate = format !== \"percent\" && Math.abs(value) >= compactFrom\n  if (!abbreviate) {\n    return (\n      <span data-slot=\"metric-value\" className={classes} {...props}>\n        {full}\n        {suffix}\n      </span>\n    )\n  }\n\n  return (\n    <TooltipProvider>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <span\n            data-slot=\"metric-value\"\n            data-compact=\"\"\n            className={cn(classes, \"cursor-default\")}\n            {...props}\n          >\n            {formatNumber(value, { format, currency, compact: true })}\n            {suffix}\n          </span>\n        </TooltipTrigger>\n        <TooltipContent className=\"tabular-nums\">\n          {full}\n          {suffix}\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  )\n}\n\nexport { MetricValue }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/dashboardcn/lib/format.ts",
      "content": "export type NumberFormat = \"number\" | \"compact\" | \"currency\" | \"percent\"\n\nexport interface FormatNumberOptions {\n  format?: NumberFormat\n  /** ISO 4217 code, used when format is \"currency\". Defaults to USD. */\n  currency?: string\n  locale?: string\n  maximumFractionDigits?: number\n  /** Abbreviate large values, e.g. \"$158K\" instead of \"$158,143\". */\n  compact?: boolean\n}\n\nconst formatterCache = new Map<string, Intl.NumberFormat>()\n\nfunction getFormatter(locale: string, options: Intl.NumberFormatOptions) {\n  const key = locale + JSON.stringify(options)\n  let formatter = formatterCache.get(key)\n  if (!formatter) {\n    formatter = new Intl.NumberFormat(locale, options)\n    formatterCache.set(key, formatter)\n  }\n  return formatter\n}\n\n/**\n * Format a metric value for display.\n *\n * formatNumber(1234567)                          -> \"1,234,567\"\n * formatNumber(1234567, { format: \"compact\" })   -> \"1.2M\"\n * formatNumber(48.2, { format: \"currency\" })     -> \"$48.20\"\n * formatNumber(158143, { format: \"currency\", compact: true }) -> \"$158K\"\n * formatNumber(0.124, { format: \"percent\" })     -> \"12.4%\"\n */\nexport function formatNumber(\n  value: number,\n  {\n    format = \"number\",\n    currency = \"USD\",\n    locale = \"en-US\",\n    maximumFractionDigits,\n    compact = false,\n  }: FormatNumberOptions = {}\n): string {\n  if (!Number.isFinite(value)) return \"—\"\n\n  // Compact values keep three significant digits: $158K, $1.23M, 41.2K.\n  const compactOptions: Intl.NumberFormatOptions = compact\n    ? maximumFractionDigits === undefined\n      ? { notation: \"compact\", maximumSignificantDigits: 3 }\n      : { notation: \"compact\", maximumFractionDigits }\n    : {}\n  switch (format) {\n    case \"compact\":\n      return getFormatter(locale, {\n        notation: \"compact\",\n        maximumFractionDigits: maximumFractionDigits ?? 1,\n      }).format(value)\n    case \"currency\":\n      return getFormatter(locale, {\n        style: \"currency\",\n        currency,\n        maximumFractionDigits: maximumFractionDigits ?? 2,\n        ...compactOptions,\n      }).format(value)\n    case \"percent\":\n      return getFormatter(locale, {\n        style: \"percent\",\n        maximumFractionDigits: maximumFractionDigits ?? 1,\n      }).format(value)\n    default:\n      return getFormatter(locale, {\n        maximumFractionDigits: maximumFractionDigits ?? 0,\n        ...compactOptions,\n      }).format(value)\n  }\n}\n\n/**\n * Format a fractional change as a signed percentage.\n *\n * formatDelta(0.124)  -> \"+12.4%\"\n * formatDelta(-0.03)  -> \"-3.0%\"\n * formatDelta(0)      -> \"0.0%\"\n */\nexport function formatDelta(delta: number, locale = \"en-US\"): string {\n  if (!Number.isFinite(delta)) return \"—\"\n  return getFormatter(locale, {\n    style: \"percent\",\n    signDisplay: \"exceptZero\",\n    maximumFractionDigits: 1,\n    minimumFractionDigits: 1,\n  }).format(delta)\n}\n\n/**\n * Fractional change between two values. Returns 0 when there is no baseline.\n */\nexport function computeDelta(current: number, previous: number): number {\n  if (!previous) return 0\n  return (current - previous) / Math.abs(previous)\n}\n",
      "type": "registry:lib"
    }
  ],
  "categories": [
    "dashboard",
    "charts"
  ],
  "type": "registry:block"
}