{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "calendar-heatmap",
  "type": "registry:ui",
  "title": "CalendarHeatmap",
  "description": "GitHub-contributions-style activity heatmap, in squares or check-in dots.",
  "categories": [
    "display"
  ],
  "registryDependencies": [
    "https://whiskeyjack.net/r/utils.json"
  ],
  "files": [
    {
      "path": "components/ui/calendar-heatmap.tsx",
      "type": "registry:ui",
      "target": "components/ui/calendar-heatmap.tsx",
      "content": "import * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface CalendarHeatmapCell {\n  /** yyyy-MM-dd */\n  date: string;\n  /** activity count for that day (e.g. number of goals checked in) */\n  count: number;\n}\n\nexport interface CalendarHeatmapProps {\n  /** Contiguous daily cells, oldest first (one per day). */\n  data: CalendarHeatmapCell[];\n  /** Count that maps to the most intense color. Defaults to the data max\n   * (so a single-goal heatmap reads as on/off). */\n  maxCount?: number;\n  /** Accessible summary for the whole graphic (role=\"img\"). The app passes a\n   * translated string. */\n  label?: string;\n  /** Legend end labels (translated by the app). */\n  lessLabel?: string;\n  moreLabel?: string;\n  /** Localized 3-letter month abbreviations, Jan..Dec (12). Falls back to en. */\n  monthLabels?: string[];\n  /** Cell shape: \"rounded\" (GitHub-style squares, default) or \"dot\" (circles,\n   * to echo a dot-grid check-in motif). */\n  shape?: \"rounded\" | \"dot\";\n  className?: string;\n  /** Inline styles for the root element. */\n  style?: React.CSSProperties;\n}\n\nconst CELL = 12; // px\nconst GAP = 3; // px\nconst EN_MONTHS = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"];\n\nconst parseYMD = (s: string): Date => {\n  const [y, m, d] = s.split(\"-\").map(Number);\n  return new Date(y, m - 1, d);\n};\nconst fmtYMD = (d: Date): string =>\n  `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, \"0\")}-${String(d.getDate()).padStart(2, \"0\")}`;\nconst addDays = (d: Date, n: number): Date => {\n  const r = new Date(d);\n  r.setDate(r.getDate() + n);\n  return r;\n};\n/** Monday-based weekday index: Mon=0 .. Sun=6. */\nconst mondayIndex = (d: Date): number => (d.getDay() + 6) % 7;\nconst dayDiff = (a: Date, b: Date): number => Math.round((a.getTime() - b.getTime()) / 86_400_000);\n\n/** Faint track for empty days; filled days step up the accent. */\nfunction cellColor(count: number, max: number): string {\n  if (count <= 0) return \"color-mix(in srgb, var(--color-accent-500) 10%, transparent)\";\n  const ratio = max > 0 ? count / max : 1;\n  const level = Math.min(4, Math.max(1, Math.ceil(ratio * 4)));\n  const pct = [40, 60, 80, 100][level - 1];\n  return `color-mix(in srgb, var(--color-accent-500) ${pct}%, transparent)`;\n}\n\n/**\n * A GitHub-contributions-style calendar heatmap. Lays the supplied daily cells\n * into a Monday-top week grid (columns = weeks), coloring each by its count\n * against `maxCount`. The grid is fluid: it caps at its natural cell size on wide\n * screens and shrinks the cells to fit narrower (mobile) containers, so a long\n * range never overflows or scrolls horizontally. The legend keeps a fixed cell\n * size. The whole grid is a single `role=\"img\"` with `label` as its description;\n * per-cell `title`s give sighted hover detail.\n */\nexport const CalendarHeatmap = React.forwardRef<HTMLDivElement, CalendarHeatmapProps>(function CalendarHeatmap({\n  data,\n  maxCount,\n  label,\n  lessLabel = \"Less\",\n  moreLabel = \"More\",\n  monthLabels = EN_MONTHS,\n  shape = \"rounded\",\n  className,\n  style,\n}, ref) {\n  const cellRadius = shape === \"dot\" ? \"50%\" : 3;\n  if (data.length === 0) return null;\n\n  const counts = new Map(data.map((c) => [c.date, c.count]));\n  const firstDate = parseYMD(data[0].date);\n  const lastDate = parseYMD(data[data.length - 1].date);\n  // Pad back to the Monday on/before the first day so each column is a full week.\n  const gridStart = addDays(firstDate, -mondayIndex(firstDate));\n  const cols = Math.ceil((dayDiff(lastDate, gridStart) + 1) / 7);\n  const max = maxCount ?? Math.max(1, ...data.map((c) => c.count));\n  const total = data.reduce((s, c) => s + c.count, 0);\n  const ariaLabel = label ?? `Activity heatmap: ${total} check-ins over ${data.length} days`;\n\n  const cells: React.ReactNode[] = [];\n  const months: { col: number; text: string }[] = [];\n  let prevMonth = -1;\n  for (let col = 0; col < cols; col++) {\n    const colDate = addDays(gridStart, col * 7);\n    const m = colDate.getMonth();\n    if (m !== prevMonth) {\n      months.push({ col, text: monthLabels[m] ?? EN_MONTHS[m] });\n      prevMonth = m;\n    }\n    for (let row = 0; row < 7; row++) {\n      const date = addDays(gridStart, col * 7 + row);\n      const key = fmtYMD(date);\n      const count = counts.get(key);\n      const inRange = count !== undefined;\n      cells.push(\n        <div\n          key={key}\n          aria-hidden=\"true\"\n          title={inRange ? `${key}: ${count}` : undefined}\n          style={{\n            gridColumn: col + 1,\n            gridRow: row + 1,\n            aspectRatio: \"1\",\n            borderRadius: cellRadius,\n            backgroundColor: inRange ? cellColor(count, max) : \"transparent\",\n          }}\n        />,\n      );\n    }\n  }\n\n  const mutedText =\n    \"text-[var(--color-text-secondary-light)] dark:text-[var(--color-text-secondary-dark)]\";\n\n  // Desktop cap: the grid never grows past its natural CELL-sized footprint, so\n  // it reads identically to before on wide screens. On narrow (mobile) widths the\n  // 1fr columns shrink the cells to fit the container, so the whole range fits with\n  // no horizontal scroll -- which would otherwise fight the tab-swipe gesture.\n  const gridMaxWidth = cols * CELL + (cols - 1) * GAP;\n\n  return (\n    <div ref={ref} className={cn(\"w-full\", className)} style={style} role=\"img\" aria-label={ariaLabel}>\n      <div className=\"w-full\" style={{ maxWidth: gridMaxWidth }}>\n        <div className={cn(\"relative h-4 mb-1 text-[10px]\", mutedText)} aria-hidden=\"true\">\n          {months.map((ml) => (\n            <span\n              key={ml.col}\n              className=\"absolute top-0\"\n              style={{ left: `${(ml.col / cols) * 100}%` }}\n            >\n              {ml.text}\n            </span>\n          ))}\n        </div>\n        <div\n          className=\"grid\"\n          style={{\n            gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,\n            gridTemplateRows: `repeat(7, auto)`,\n            gap: GAP,\n          }}\n        >\n          {cells}\n        </div>\n      </div>\n      <div className={cn(\"flex items-center gap-1 mt-2 text-[10px]\", mutedText)} aria-hidden=\"true\">\n        <span>{lessLabel}</span>\n        {[0, 1, 2, 3, 4].map((l) => (\n          <span\n            key={l}\n            style={{\n              width: CELL,\n              height: CELL,\n              borderRadius: cellRadius,\n              backgroundColor: l === 0 ? cellColor(0, 4) : cellColor(l, 4),\n            }}\n          />\n        ))}\n        <span>{moreLabel}</span>\n      </div>\n    </div>\n  );\n});\n\nCalendarHeatmap.displayName = \"CalendarHeatmap\";\n"
    }
  ],
  "docs": "Feed it contiguous daily cells (oldest first) and it lays them into a Monday-top week grid. The grid is fluid, so a long range shrinks to fit a mobile container rather than scrolling horizontally and fighting tab-swipe gestures. Presentational and i18n-agnostic: pass translated label, lessLabel, moreLabel, and monthLabels.",
  "meta": {
    "group": "display",
    "related": [
      "progress-bar"
    ],
    "exports": [
      "CalendarHeatmap"
    ],
    "siteSlug": "calendar-heatmap"
  }
}
