{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "status-heatmap",
  "type": "registry:component",
  "title": "StatusHeatmap",
  "description": "Timeline status indicator showing daily activity over a period (e.g. 90 days). Similar to Atlassian Statuspage.",
  "dependencies": [
    "clsx",
    "tailwind-merge",
    "date-fns"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "src/components/heatmap/status-heatmap.tsx",
      "type": "registry:component",
      "target": "components/heatmap/status-heatmap.tsx",
      "content": "import type { Locale } from \"date-fns\";\nimport { format } from \"date-fns\";\nimport type { CSSProperties, HTMLAttributes, ReactNode } from \"react\";\nimport { createContext, Fragment, use, useMemo } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n// Status values. 0 is reserved for no-data. Defaults: 1=critical, 2=degraded, 3=healthy.\n// Extend by passing `colors` / `labels.statuses` keyed by the numeric value.\nexport type StatusValue = number;\n\nexport type StatusActivity = {\n  date: string; // YYYY-MM-DD\n  value: StatusValue;\n};\n\ntype StatusHeatmapContextType = {\n  data: StatusActivity[];\n  dates: string[];\n  blockMargin: number;\n  blockRadius: number;\n  blockSize: number;\n  blockAspectRatio: number;\n  blockWidth: number;\n  fontSize: number;\n  labels: StatusHeatmapLabels;\n  statusValues: StatusValue[];\n  healthyValue: StatusValue;\n  width: number;\n  height: number;\n  dateFormat: string;\n  locale?: Locale;\n  colors?: StatusColorConfig;\n};\n\nexport type StatusHeatmapLabels = {\n  statuses?: Record<number, string> & {\n    noData?: string;\n    critical?: string;\n    degraded?: string;\n    healthy?: string;\n  };\n  stat?: string; // Stat text template. Placeholder: {{value}}\n  cellLabel?: string; // aria-label template. Placeholders: {{date}}, {{status}}\n  heatmapLabel?: string; // aria-label for the heatmap SVG\n  legendLabel?: string; // aria-label for the legend fieldset\n};\n\nexport type StatusColorConfig = Record<number, string> & {\n  noData?: string;\n  critical?: string;\n  degraded?: string;\n  healthy?: string;\n};\n\nconst DEFAULT_COLORS: Record<number, string> = {\n  0: \"var(--color-secondary)\",\n  1: \"oklch(57.7% 0.245 27.325)\", // red-600 (critical)\n  2: \"oklch(82.8% 0.189 84.429)\", // amber-400 (degraded)\n  3: \"oklch(72.3% 0.219 149.579)\", // green-500 (healthy)\n};\n\nconst SEMANTIC_KEYS: Record<\n  number,\n  \"noData\" | \"critical\" | \"degraded\" | \"healthy\"\n> = {\n  0: \"noData\",\n  1: \"critical\",\n  2: \"degraded\",\n  3: \"healthy\",\n};\n\nconst resolveColor = (\n  value: StatusValue,\n  colors?: StatusColorConfig,\n): string => {\n  const semanticKey = SEMANTIC_KEYS[value];\n  if (colors) {\n    const numeric = colors[value];\n    if (typeof numeric === \"string\") return numeric;\n    if (semanticKey) {\n      const semantic = colors[semanticKey];\n      if (typeof semantic === \"string\") return semantic;\n    }\n  }\n  return DEFAULT_COLORS[value] ?? DEFAULT_COLORS[0];\n};\n\nconst resolveStatusLabel = (\n  value: StatusValue,\n  labels?: StatusHeatmapLabels[\"statuses\"],\n): string => {\n  if (labels) {\n    const numeric = labels[value];\n    if (typeof numeric === \"string\") return numeric;\n    const semanticKey = SEMANTIC_KEYS[value];\n    if (semanticKey) {\n      const semantic = labels[semanticKey];\n      if (typeof semantic === \"string\") return semantic;\n    }\n  }\n  switch (value) {\n    case 0:\n      return \"No Data\";\n    case 1:\n      return \"Critical\";\n    case 2:\n      return \"Degraded\";\n    case 3:\n      return \"Healthy\";\n    default:\n      return `Status ${value}`;\n  }\n};\n\nconst getStatusFill = (\n  value: StatusValue,\n  colors?: StatusColorConfig,\n  highlighted = false,\n): string => {\n  const base = resolveColor(value, colors);\n  if (!highlighted) return base;\n  return `color-mix(in oklch, ${base} 60%, transparent)`;\n};\n\nconst EMPTY_STYLE: CSSProperties = {};\nconst PADDING = 20;\n\nconst StatusHeatmapContext = createContext<StatusHeatmapContextType | null>(\n  null,\n);\n\nconst useStatusHeatmap = () => {\n  const context = use(StatusHeatmapContext);\n\n  if (!context) {\n    throw new Error(\n      \"StatusHeatmap components must be used within a StatusHeatmap\",\n    );\n  }\n\n  return context;\n};\n\nexport type StatusHeatmapProps = HTMLAttributes<HTMLDivElement> & {\n  data: StatusActivity[];\n  dateFormat?: string;\n  blockSize?: number;\n  blockMargin?: number;\n  blockRadius?: number;\n  blockAspectRatio?: number;\n  colors?: StatusColorConfig;\n  locale?: Locale;\n  labels?: StatusHeatmapLabels;\n  fontSize?: number;\n  emptyState?: ReactNode;\n  statusValues?: StatusValue[]; // Legend order. Defaults to [0, 1, 2, 3]\n  healthyValue?: StatusValue; // Value counted by the default StatusHeatmapStat compute. Default: 3\n  style?: CSSProperties;\n  className?: string;\n  children: ReactNode;\n};\n\n/**\n * Status Heatmap\n *\n * A timeline indicator showing daily status over a period (e.g., 90 days).\n * Similar to Atlassian Statuspage - each day is represented by a vertical bar.\n * Supports 4 status values: 0=no-data, 1=critical, 2=degraded, 3=healthy\n *\n * @example\n * ```tsx\n * <StatusHeatmap data={data} blockAspectRatio={0.2}>\n *   <StatusHeatmapBody>\n *     {({ activity, dayIndex }) => (\n *       <StatusHeatmapBlock\n *         activity={activity}\n *         dayIndex={dayIndex}\n *       />\n *     )}\n *   </StatusHeatmapBody>\n *   <StatusHeatmapFooter>\n *     <StatusHeatmapStat />\n *     <StatusHeatmapLegend />\n *   </StatusHeatmapFooter>\n * </StatusHeatmap>\n * ```\n *\n * @param data - Array of activities with date (YYYY-MM-DD) and status value (0-3)\n * @param dateFormat - Date format string for tooltips. Default: \"MMM d\"\n * @param blockAspectRatio - Width/height ratio of blocks. Default: 0.2 (narrow vertical bars)\n * @param colors - Custom colors for critical, degraded, and healthy states\n */\nexport const StatusHeatmap = ({\n  data,\n  dateFormat = \"MMM d\",\n  blockSize = 40,\n  blockMargin = 2,\n  blockRadius = 2,\n  blockAspectRatio = 0.2,\n  colors,\n  locale,\n  labels: labelsProp,\n  fontSize = 14,\n  emptyState,\n  statusValues = [0, 1, 2, 3],\n  healthyValue = 3,\n  style = EMPTY_STYLE,\n  className,\n  children,\n  ...props\n}: StatusHeatmapProps) => {\n  const labels = useMemo<StatusHeatmapLabels>(\n    () => ({\n      statuses: {\n        noData: \"No Data\",\n        critical: \"Critical\",\n        degraded: \"Degraded\",\n        healthy: \"Healthy\",\n      },\n      cellLabel: \"{{date}}: {{status}}\",\n      heatmapLabel: \"Status heatmap\",\n      legendLabel: \"Status legend\",\n      ...labelsProp,\n    }),\n    [labelsProp],\n  );\n\n  const dates = useMemo(() => {\n    const uniqueDates = new Set<string>();\n    data.forEach((activity) => {\n      uniqueDates.add(activity.date);\n    });\n    return Array.from(uniqueDates).sort();\n  }, [data]);\n\n  const blockWidth = blockSize * blockAspectRatio;\n  const width = dates.length * (blockWidth + blockMargin) - blockMargin;\n  const height = blockSize;\n\n  const contextValue = useMemo<StatusHeatmapContextType>(\n    () => ({\n      data,\n      dates,\n      blockMargin,\n      blockRadius,\n      blockSize,\n      blockAspectRatio,\n      blockWidth,\n      fontSize,\n      labels,\n      statusValues,\n      healthyValue,\n      width,\n      height,\n      dateFormat,\n      locale,\n      colors,\n    }),\n    [\n      data,\n      dates,\n      blockMargin,\n      blockRadius,\n      blockSize,\n      blockAspectRatio,\n      blockWidth,\n      fontSize,\n      labels,\n      statusValues,\n      healthyValue,\n      width,\n      height,\n      dateFormat,\n      locale,\n      colors,\n    ],\n  );\n\n  if (data.length === 0 || dates.length === 0) {\n    return emptyState ? emptyState : null;\n  }\n\n  return (\n    <StatusHeatmapContext value={contextValue}>\n      <div\n        data-slot=\"status-heatmap\"\n        className={cn(\"flex w-max max-w-full flex-col gap-2 p-4\", className)}\n        style={{ fontSize, ...style }}\n        {...props}\n      >\n        {children}\n      </div>\n    </StatusHeatmapContext>\n  );\n};\n\nexport type StatusHeatmapBlockProps = HTMLAttributes<SVGRectElement> & {\n  activity: StatusActivity;\n  dayIndex: number;\n  highlighted?: boolean;\n  onCellClick?: (activity: StatusActivity) => void;\n  onCellHover?: (activity: StatusActivity | null) => void;\n  className?: string;\n};\n\nexport const StatusHeatmapBlock = ({\n  ref,\n  activity,\n  dayIndex,\n  highlighted = false,\n  onCellClick,\n  onCellHover,\n  onClick,\n  onKeyDown,\n  onMouseEnter,\n  onMouseLeave,\n  className,\n  style: styleProp,\n  ...props\n}: StatusHeatmapBlockProps & {\n  ref?: React.RefObject<SVGRectElement | null>;\n}) => {\n  const { blockSize, blockWidth, blockMargin, blockRadius, labels, colors } =\n    useStatusHeatmap();\n\n  const statusText = resolveStatusLabel(activity.value, labels.statuses);\n\n  const ariaLabel = (labels.cellLabel ?? \"{{date}}: {{status}}\")\n    .replace(\"{{date}}\", activity.date)\n    .replace(\"{{status}}\", statusText);\n\n  const xPosition = (blockWidth + blockMargin) * dayIndex;\n\n  return (\n    <rect\n      ref={ref}\n      data-slot=\"status-heatmap-block\"\n      role={onCellClick ? \"button\" : \"img\"}\n      tabIndex={onCellClick ? 0 : -1}\n      aria-label={ariaLabel}\n      className={cn(\n        \"motion-safe:transition-opacity motion-safe:hover:opacity-70\",\n        onCellClick &&\n          \"cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n        className,\n      )}\n      data-value={activity.value}\n      data-date={activity.date}\n      data-highlighted={highlighted || undefined}\n      height={blockSize}\n      rx={blockRadius}\n      ry={blockRadius}\n      width={blockWidth}\n      x={xPosition}\n      y={0}\n      style={{\n        fill: getStatusFill(activity.value, colors, highlighted),\n        ...styleProp,\n      }}\n      onClick={(event) => {\n        onCellClick?.(activity);\n        onClick?.(event);\n      }}\n      onKeyDown={(event) => {\n        if (onCellClick && (event.key === \"Enter\" || event.key === \" \")) {\n          event.preventDefault();\n          onCellClick(activity);\n        }\n        onKeyDown?.(event);\n      }}\n      onMouseEnter={(event) => {\n        onCellHover?.(activity);\n        onMouseEnter?.(event);\n      }}\n      onMouseLeave={(event) => {\n        onCellHover?.(null);\n        onMouseLeave?.(event);\n      }}\n      {...props}\n    />\n  );\n};\nStatusHeatmapBlock.displayName = \"StatusHeatmapBlock\";\n\nexport type StatusHeatmapBodyProps = Omit<\n  HTMLAttributes<HTMLDivElement>,\n  \"children\"\n> & {\n  hideDateLabels?: boolean;\n  labelInterval?: number; // Show label every N days\n  className?: string;\n  labelClassName?: string;\n  children: (props: {\n    activity: StatusActivity;\n    dayIndex: number;\n  }) => ReactNode;\n};\n\nexport const StatusHeatmapBody = ({\n  hideDateLabels = false,\n  labelInterval = 30,\n  className,\n  labelClassName,\n  children,\n  ...props\n}: StatusHeatmapBodyProps) => {\n  const {\n    data,\n    dates,\n    width,\n    height,\n    blockWidth,\n    blockMargin,\n    fontSize,\n    dateFormat,\n    locale,\n    labels,\n  } = useStatusHeatmap();\n\n  const activityMap = useMemo(() => {\n    const map = new Map<string, StatusActivity>();\n    data.forEach((activity) => {\n      map.set(activity.date, activity);\n    });\n    return map;\n  }, [data]);\n\n  const allActivities = useMemo(() => {\n    return dates.map((date) => {\n      const existing = activityMap.get(date);\n      return (\n        existing || {\n          date,\n          value: 0 as StatusValue,\n        }\n      );\n    });\n  }, [dates, activityMap]);\n\n  const labelFontSize = fontSize * 0.75;\n  const labelHeight = hideDateLabels ? 0 : labelFontSize * 2;\n\n  return (\n    <div\n      data-slot=\"status-heatmap-body\"\n      className={cn(\"max-w-full overflow-x-auto overflow-y-hidden\", className)}\n      {...props}\n    >\n      <svg\n        role=\"img\"\n        aria-label={labels.heatmapLabel ?? \"Status heatmap\"}\n        className=\"block overflow-visible rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n        height={height + labelHeight + PADDING * 2}\n        viewBox={`${-PADDING} ${-PADDING} ${width + PADDING * 2} ${height + labelHeight + PADDING * 2}`}\n        width={width + PADDING * 2}\n      >\n        {!hideDateLabels && (\n          <g className={cn(\"fill-current font-mono\", labelClassName)}>\n            {dates.map((date, dayIndex) => {\n              const isFirst = dayIndex === 0;\n              const isLast = dayIndex === dates.length - 1;\n              if (dayIndex % labelInterval !== 0 && !isLast) return null;\n\n              const blockLeft = (blockWidth + blockMargin) * dayIndex;\n              const xPosition = isFirst\n                ? blockLeft\n                : isLast\n                  ? blockLeft + blockWidth\n                  : blockLeft + blockWidth / 2;\n              const anchor = isFirst ? \"start\" : isLast ? \"end\" : \"middle\";\n\n              return (\n                <text\n                  key={`date-${date}`}\n                  x={xPosition}\n                  y={height + labelFontSize}\n                  dominantBaseline=\"hanging\"\n                  textAnchor={anchor}\n                  style={{ fontSize: `${labelFontSize}px` }}\n                >\n                  {format(new Date(date), dateFormat, { locale })}\n                </text>\n              );\n            })}\n          </g>\n        )}\n\n        {allActivities.map((activity, dayIndex) => {\n          const key = activity.date;\n          return (\n            <Fragment key={key}>{children({ activity, dayIndex })}</Fragment>\n          );\n        })}\n      </svg>\n    </div>\n  );\n};\n\nexport type StatusHeatmapFooterProps = HTMLAttributes<HTMLDivElement>;\n\nexport const StatusHeatmapFooter = ({\n  className,\n  ...props\n}: StatusHeatmapFooterProps) => (\n  <div\n    data-slot=\"status-heatmap-footer\"\n    className={cn(\n      \"flex flex-wrap gap-1 whitespace-nowrap sm:gap-x-4\",\n      className,\n    )}\n    {...props}\n  />\n);\n\nexport type StatusHeatmapStatProps = Omit<\n  HTMLAttributes<HTMLDivElement>,\n  \"children\"\n> & {\n  compute?: (data: StatusActivity[]) => number | string;\n  label?: string; // Template overriding labels.stat. Placeholder: {{value}}\n  children?: (result: {\n    value: number | string;\n    data: StatusActivity[];\n  }) => ReactNode;\n};\n\nexport const StatusHeatmapStat = ({\n  compute,\n  label,\n  className,\n  children,\n  ...props\n}: StatusHeatmapStatProps) => {\n  const { data, healthyValue, labels } = useStatusHeatmap();\n\n  const value = compute\n    ? compute(data)\n    : data.filter((a) => a.value === healthyValue).length;\n\n  if (children) {\n    return <>{children({ value, data })}</>;\n  }\n\n  const template = label ?? labels.stat ?? \"{{value}} days healthy\";\n\n  return (\n    <div\n      data-slot=\"status-heatmap-stat\"\n      className={cn(\"text-muted-foreground tabular-nums\", className)}\n      {...props}\n    >\n      {template.replace(\"{{value}}\", String(value))}\n    </div>\n  );\n};\n\nexport type StatusHeatmapLegendProps = Omit<\n  HTMLAttributes<HTMLFieldSetElement>,\n  \"children\"\n> & {\n  labels?: StatusHeatmapLabels[\"statuses\"];\n  children?: (props: { value: StatusValue; label: string }) => ReactNode;\n};\n\nexport const StatusHeatmapLegend = ({\n  labels: labelsProp,\n  className,\n  children,\n  ...props\n}: StatusHeatmapLegendProps) => {\n  const { labels, statusValues, blockSize, blockWidth, blockRadius, colors } =\n    useStatusHeatmap();\n\n  const statuses = statusValues.map((value) => ({\n    value,\n    label: resolveStatusLabel(value, labelsProp ?? labels.statuses),\n  }));\n\n  return (\n    <fieldset\n      data-slot=\"status-heatmap-legend\"\n      aria-label={labels.legendLabel ?? \"Status legend\"}\n      className={cn(\"ml-auto flex items-center gap-1\", className)}\n      {...props}\n    >\n      {statuses.map((status) =>\n        children ? (\n          <Fragment key={`status-${status.value}`}>\n            {children({ value: status.value, label: status.label })}\n          </Fragment>\n        ) : (\n          <div\n            key={`status-${status.value}`}\n            className=\"flex items-center gap-1\"\n          >\n            <svg aria-hidden=\"true\" height={blockSize / 2} width={blockWidth}>\n              <rect\n                data-value={status.value}\n                height={blockSize / 2}\n                rx={blockRadius}\n                ry={blockRadius}\n                width={blockWidth}\n                style={{ fill: getStatusFill(status.value, colors) }}\n              />\n            </svg>\n            <span className=\"font-medium text-muted-foreground text-xs\">\n              {status.label}\n            </span>\n          </div>\n        ),\n      )}\n    </fieldset>\n  );\n};\n"
    }
  ],
  "cssVars": {
    "theme": {
      "secondary": "oklch(96.7% 0.001 286.4)",
      "chart-1": "oklch(64.6% 0.222 41.1)",
      "muted-foreground": "oklch(55.2% 0.014 285.9)"
    }
  }
}
