{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "progress-card",
  "title": "Progress Card",
  "description": "A compact progress card with an icon title, a pill tick bar, and a count with a secondary stat.",
  "registryDependencies": [
    "card",
    "tooltip"
  ],
  "files": [
    {
      "path": "registry/dashboardcn/blocks/progress-card.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n  Card,\n  CardAction,\n  CardContent,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\"\nimport { TickBar } from \"@/registry/dashboardcn/ui/tick-bar\"\nimport { MetricValue } from \"@/registry/dashboardcn/ui/metric-value\"\n\nexport interface ProgressCardProps extends React.ComponentProps<typeof Card> {\n  title: string\n  icon?: React.ReactNode\n  /** Slot in the top-right corner, e.g. a menu button. */\n  action?: React.ReactNode\n  value: number\n  max: number\n  /** Noun after the count, e.g. \"checks passing\" renders \"16/24 checks passing\". */\n  label?: string\n  /** Secondary stat on the right of the footer, e.g. \"67% assigned\". */\n  detail?: React.ReactNode\n  /** Any CSS color for the filled ticks. Defaults to the foreground color. */\n  color?: string\n  /** Number of ticks in the bar. */\n  segments?: number\n  shape?: \"tick\" | \"pill\"\n}\n\n/** A compact progress card with an icon title, a pill tick bar, and a count with a secondary stat. */\nfunction ProgressCard({\n  title,\n  icon,\n  action,\n  value,\n  max,\n  label,\n  detail,\n  color = \"var(--foreground)\",\n  segments = 24,\n  shape = \"pill\",\n  className,\n  ...props\n}: ProgressCardProps) {\n  return (\n    <Card data-slot=\"progress-card\" className={cn(\"gap-4\", className)} {...props}>\n      <CardHeader>\n        <CardTitle className=\"flex items-center gap-2 text-sm [&>svg]:size-4\">\n          {icon}\n          <span className=\"truncate\">{title}</span>\n        </CardTitle>\n        {action ? <CardAction>{action}</CardAction> : null}\n      </CardHeader>\n      <CardContent>\n        <TickBar value={value} max={max} segments={segments} shape={shape} color={color} />\n      </CardContent>\n      {label || detail ? (\n        <CardFooter className=\"justify-between gap-4 text-sm\">\n          <span className=\"text-muted-foreground tabular-nums\">\n            <MetricValue value={value} />/<MetricValue value={max} />\n            {label ? ` ${label}` : null}\n          </span>\n          {detail ? <span className=\"shrink-0 font-semibold\">{detail}</span> : null}\n        </CardFooter>\n      ) : null}\n    </Card>\n  )\n}\n\nexport { ProgressCard }\n",
      "type": "registry:component"
    },
    {
      "path": "registry/dashboardcn/ui/tick-bar.tsx",
      "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface TickBarProps extends React.ComponentProps<\"div\"> {\n  /** Current value, from 0 to max. */\n  value: number\n  max?: number\n  /** Number of ticks. */\n  segments?: number\n  /** Any CSS color for the filled ticks. Defaults to chart-1. */\n  color?: string\n  /** Any CSS color for the unfilled ticks. Defaults to muted. */\n  trackColor?: string\n  /** \"tick\" draws thin bars; \"pill\" draws wide rounded ones. */\n  shape?: \"tick\" | \"pill\"\n  /** Reveal filled ticks in sequence when the bar mounts. */\n  animate?: boolean\n  /** Total reveal time in milliseconds. */\n  animationDuration?: number\n}\n\n/** A progress bar drawn as a row of ticks, lit up to the current value. */\nfunction TickBar({\n  value,\n  max = 100,\n  segments = 40,\n  color = \"var(--chart-1)\",\n  trackColor,\n  shape = \"tick\",\n  animate = false,\n  animationDuration = 700,\n  className,\n  ...props\n}: TickBarProps) {\n  const fraction = Math.min(1, Math.max(0, value / (max || 1)))\n  // Any non-zero value lights at least one tick so small values still register.\n  const filled = fraction === 0 ? 0 : Math.max(1, Math.round(fraction * segments))\n\n  return (\n    <div\n      data-slot=\"tick-bar\"\n      data-shape={shape}\n      role=\"progressbar\"\n      aria-valuemin={0}\n      aria-valuemax={max}\n      aria-valuenow={value}\n      className={cn(\n        \"flex w-full\",\n        shape === \"tick\" ? \"h-6 gap-[3px]\" : \"h-4 gap-1.5\",\n        className\n      )}\n      {...props}\n    >\n      {Array.from({ length: segments }, (_, index) => {\n        const lit = index < filled\n        return (\n          <span\n            key={index}\n            data-filled={lit}\n            className={cn(\n              \"bg-muted relative h-full min-w-0 flex-1 overflow-hidden transition-colors\",\n              shape === \"tick\" ? \"rounded-[1.5px]\" : \"rounded-full\"\n            )}\n            style={{ backgroundColor: trackColor }}\n          >\n            {lit ? (\n              <span\n                className={cn(\n                  \"absolute inset-0\",\n                  shape === \"tick\" ? \"rounded-[1.5px]\" : \"rounded-full\",\n                  animate && \"motion-safe:animate-in motion-safe:fade-in\"\n                )}\n                style={{\n                  backgroundColor: color,\n                  ...(animate\n                    ? {\n                        animationDelay: `${(index / Math.max(1, filled)) * animationDuration * 0.8}ms`,\n                        animationDuration: `${Math.max(120, animationDuration * 0.25)}ms`,\n                        animationFillMode: \"both\",\n                      }\n                    : null),\n                }}\n              />\n            ) : null}\n          </span>\n        )\n      })}\n    </div>\n  )\n}\n\nexport { TickBar }\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"
  ],
  "type": "registry:block"
}