{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "activity-heatmap",
  "type": "registry:ui",
  "title": "Activity Heatmap",
  "description": "A contribution-style calendar heatmap with keyboard navigation and focus-aware tooltips.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/activity-heatmap.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport interface HeatmapDatum {\n  /** ISO date, \"YYYY-MM-DD\" (time and zone are ignored). */\n  date: string;\n  count: number;\n}\n\nexport interface ActivityHeatmapProps extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  data: HeatmapDatum[];\n  /** Colour of the highest activity level; lower levels are mixed toward transparent. */\n  accent?: string;\n  /** First row of each week column. */\n  weekStart?: \"sunday\" | \"monday\";\n  /** \"fit\" scales cells to the container width; \"scroll\" keeps `cellSize` and scrolls horizontally. */\n  layout?: \"fit\" | \"scroll\";\n  /** Cell size in px (scroll layout). */\n  cellSize?: number;\n  /** Gap between cells in px. */\n  gap?: number;\n  /** Cell corner radius in px. */\n  radius?: number;\n  showMonthLabels?: boolean;\n  showWeekdayLabels?: boolean;\n  showLegend?: boolean;\n  /** Accessible name of the grid. */\n  label?: string;\n  /** Singular unit used in labels, e.g. \"contribution\". */\n  unit?: string;\n  /** Plural unit; defaults to `unit + \"s\"`. */\n  unitPlural?: string;\n  /** Overrides the first day shown (\"YYYY-MM-DD\"); defaults to the earliest date in `data`. */\n  startDate?: string;\n  /** Overrides the last day shown (\"YYYY-MM-DD\"); defaults to the latest date in `data`. */\n  endDate?: string;\n  /** Locale for dates and numbers. */\n  locale?: string;\n}\n\nconst DAY_MS = 86_400_000;\nconst LEVEL_MIX = [0, 30, 52, 76, 100];\nconst LABEL_WIDTH = 28;\n\n/** Whole days since 1970-01-01 (UTC), so dates never drift with the viewer's time zone. */\nfunction toDayNumber(iso: string): number | null {\n  const m = /^(\\d{4})-(\\d{2})-(\\d{2})/.exec(iso);\n  if (!m) return null;\n  return Math.floor(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])) / DAY_MS);\n}\n\n/** 0 = Sunday … 6 = Saturday. 1970-01-01 was a Thursday. */\nconst weekdayOf = (day: number) => (((day + 4) % 7) + 7) % 7;\n\ninterface Cell {\n  day: number;\n  count: number;\n  level: number;\n  valid: boolean;\n}\n\ninterface Model {\n  rows: Cell[][];\n  weeks: number;\n  total: number;\n  max: number;\n  busiest: Cell | null;\n  start: number;\n  end: number;\n  months: { col: number; label: string }[];\n}\n\nfunction buildModel(\n  data: HeatmapDatum[],\n  weekStart: \"sunday\" | \"monday\",\n  startDate: string | undefined,\n  endDate: string | undefined,\n  monthFormat: Intl.DateTimeFormat,\n): Model | null {\n  const counts = new Map<number, number>();\n  let min = Infinity;\n  let max = -Infinity;\n  for (const d of data) {\n    const day = toDayNumber(d.date);\n    if (day === null || !Number.isFinite(d.count)) continue;\n    counts.set(day, (counts.get(day) ?? 0) + Math.max(0, d.count));\n    min = Math.min(min, day);\n    max = Math.max(max, day);\n  }\n  const explicitStart = startDate ? toDayNumber(startDate) : null;\n  const explicitEnd = endDate ? toDayNumber(endDate) : null;\n  const start = explicitStart ?? (Number.isFinite(min) ? min : null);\n  const end = explicitEnd ?? (Number.isFinite(max) ? max : null);\n  if (start === null || end === null || end < start) return null;\n\n  const offset = weekStart === \"monday\" ? 1 : 0;\n  const rowOf = (day: number) => (weekdayOf(day) - offset + 7) % 7;\n  const gridStart = start - rowOf(start);\n  const weeks = Math.floor((end - gridStart) / 7) + 1;\n\n  let total = 0;\n  let peak = 0;\n  for (let day = start; day <= end; day++) {\n    const c = counts.get(day) ?? 0;\n    total += c;\n    peak = Math.max(peak, c);\n  }\n\n  let busiest: Cell | null = null;\n  const rows: Cell[][] = [];\n  for (let r = 0; r < 7; r++) {\n    const row: Cell[] = [];\n    for (let c = 0; c < weeks; c++) {\n      const day = gridStart + c * 7 + r;\n      const valid = day >= start && day <= end;\n      const count = valid ? (counts.get(day) ?? 0) : 0;\n      const level = count <= 0 || peak === 0 ? 0 : Math.min(4, Math.ceil((count / peak) * 4));\n      const cell = { day, count, level, valid };\n      if (valid && count > 0 && (!busiest || count > busiest.count)) busiest = cell;\n      row.push(cell);\n    }\n    rows.push(row);\n  }\n\n  // A month label sits above the first column that starts in a new month.\n  const months: { col: number; label: string }[] = [];\n  let lastMonth = -1;\n  for (let c = 0; c < weeks; c++) {\n    const firstValid = rows.find((row) => row[c].valid)?.[c];\n    if (!firstValid) continue;\n    const month = new Date(firstValid.day * DAY_MS).getUTCMonth();\n    if (month === lastMonth) continue;\n    lastMonth = month;\n    const prev = months[months.length - 1];\n    // Avoid overlapping labels: a partial first month gives way to the next one.\n    if (prev && c - prev.col < 3) months.pop();\n    months.push({ col: c, label: monthFormat.format(new Date(firstValid.day * DAY_MS)) });\n  }\n\n  return { rows, weeks, total, max: peak, busiest, start, end, months };\n}\n\ninterface Tip {\n  row: number;\n  col: number;\n  x: number;\n  y: number;\n  arrow: number;\n}\n\n/**\n * A contribution-style calendar heatmap. Cells form an ARIA grid with roving focus and\n * arrow-key navigation; the same tooltip appears on hover and on keyboard focus.\n */\nexport function ActivityHeatmap({\n  data,\n  accent = \"#FF7448\",\n  weekStart = \"sunday\",\n  layout = \"fit\",\n  cellSize = 12,\n  gap = 3,\n  radius = 2,\n  showMonthLabels = true,\n  showWeekdayLabels = true,\n  showLegend = true,\n  label = \"Activity\",\n  unit = \"contribution\",\n  unitPlural,\n  startDate,\n  endDate,\n  locale = \"en-GB\",\n  className = \"\",\n  style,\n  ...props\n}: ActivityHeatmapProps) {\n  const id = React.useId();\n  const summaryId = `${id}-summary`;\n  const wrapRef = React.useRef<HTMLDivElement>(null);\n  const scrollRef = React.useRef<HTMLDivElement>(null);\n  const cellRefs = React.useRef(new Map<string, HTMLDivElement>());\n  const [active, setActive] = React.useState<{ row: number; col: number } | null>(null);\n  const [tip, setTip] = React.useState<Tip | null>(null);\n\n  const formats = React.useMemo(() => {\n    const safe = (fn: (loc: string | undefined) => Intl.DateTimeFormat | Intl.NumberFormat) => {\n      try {\n        return fn(locale);\n      } catch {\n        return fn(undefined);\n      }\n    };\n    return {\n      date: safe((l) => new Intl.DateTimeFormat(l, { day: \"numeric\", month: \"short\", year: \"numeric\", timeZone: \"UTC\" })) as Intl.DateTimeFormat,\n      weekday: safe((l) => new Intl.DateTimeFormat(l, { weekday: \"short\", timeZone: \"UTC\" })) as Intl.DateTimeFormat,\n      month: safe((l) => new Intl.DateTimeFormat(l, { month: \"short\", timeZone: \"UTC\" })) as Intl.DateTimeFormat,\n      number: safe((l) => new Intl.NumberFormat(l)) as Intl.NumberFormat,\n    };\n  }, [locale]);\n\n  const model = React.useMemo(\n    () => buildModel(data, weekStart, startDate, endDate, formats.month),\n    [data, weekStart, startDate, endDate, formats],\n  );\n\n  const plural = unitPlural ?? `${unit}s`;\n  const describe = (cell: Cell) =>\n    `${cell.count === 0 ? \"No\" : formats.number.format(cell.count)} ${cell.count === 1 ? unit : plural} on ${formats.date.format(new Date(cell.day * DAY_MS))}`;\n\n  // Keep the most recent weeks in view when the grid scrolls.\n  React.useEffect(() => {\n    const node = scrollRef.current;\n    if (node && layout === \"scroll\") node.scrollLeft = node.scrollWidth;\n  }, [layout, model]);\n\n  if (!model) {\n    return (\n      <div className={`font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 ${className}`} {...props}>\n        No activity data\n      </div>\n    );\n  }\n\n  const { rows, weeks } = model;\n  const isValid = (r: number, c: number) => r >= 0 && r < 7 && c >= 0 && c < weeks && rows[r][c].valid;\n\n  // The roving tab stop defaults to the most recent day.\n  const lastCol = weeks - 1;\n  let fallbackRow = 6;\n  while (fallbackRow > 0 && !rows[fallbackRow][lastCol].valid) fallbackRow--;\n  const focusCell = active && isValid(active.row, active.col) ? active : { row: fallbackRow, col: lastCol };\n\n  const showTip = (row: number, col: number, target: HTMLElement) => {\n    const wrap = wrapRef.current;\n    if (!wrap) return;\n    const w = wrap.getBoundingClientRect();\n    const r = target.getBoundingClientRect();\n    const centre = r.left + r.width / 2 - w.left;\n    const half = 92; // approx. half the tooltip width; keeps it inside the component\n    const x = w.width > half * 2 ? Math.min(Math.max(centre, half), w.width - half) : w.width / 2;\n    setTip({ row, col, x, y: r.top - w.top, arrow: centre - x });\n  };\n\n  const moveFocus = (row: number, col: number, dRow: number, dCol: number) => {\n    let r = row + dRow;\n    let c = col + dCol;\n    // Skip padding cells outside the date range.\n    while (r >= 0 && r < 7 && c >= 0 && c < weeks && !rows[r][c].valid) {\n      r += dRow;\n      c += dCol;\n    }\n    if (!isValid(r, c)) return;\n    setActive({ row: r, col: c });\n    cellRefs.current.get(`${r}:${c}`)?.focus();\n  };\n\n  const findInRow = (row: number, from: number, step: number) => {\n    for (let c = from; c >= 0 && c < weeks; c += step) if (rows[row][c].valid) return c;\n    return -1;\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    const { row, col } = focusCell;\n    const keys: Record<string, () => void> = {\n      ArrowLeft: () => moveFocus(row, col, 0, -1),\n      ArrowRight: () => moveFocus(row, col, 0, 1),\n      ArrowUp: () => moveFocus(row, col, -1, 0),\n      ArrowDown: () => moveFocus(row, col, 1, 0),\n      Home: () => {\n        const c = findInRow(row, 0, 1);\n        if (c >= 0) moveFocus(row, c, 0, 0);\n      },\n      End: () => {\n        const c = findInRow(row, weeks - 1, -1);\n        if (c >= 0) moveFocus(row, c, 0, 0);\n      },\n      Escape: () => setTip(null),\n    };\n    const action = keys[event.key];\n    if (!action) return;\n    event.preventDefault();\n    action();\n  };\n\n  const fit = layout === \"fit\";\n  const cellStyle: React.CSSProperties = fit\n    ? { borderRadius: radius }\n    : { width: cellSize, height: cellSize, borderRadius: radius };\n  const weekdayRows = new Set(weekStart === \"monday\" ? [0, 2, 4] : [1, 3, 5]);\n  const tipCell = tip ? rows[tip.row]?.[tip.col] : undefined;\n  const rangeText = `${formats.date.format(new Date(model.start * DAY_MS))} – ${formats.date.format(new Date(model.end * DAY_MS))}`;\n  const levelColor = (level: number) =>\n    level === 0 ? undefined : `color-mix(in oklab, var(--hm-accent) ${LEVEL_MIX[level]}%, transparent)`;\n\n  return (\n    <div\n      ref={wrapRef}\n      className={`relative w-full text-zinc-900 dark:text-zinc-100 ${className}`}\n      style={{ [\"--hm-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <p id={summaryId} className=\"sr-only\">\n        {`${formats.number.format(model.total)} ${model.total === 1 ? unit : plural} between ${rangeText}.`}\n        {model.busiest ? ` Busiest day: ${describe(model.busiest)}.` : \"\"}\n        {\" Use the arrow keys to move between days.\"}\n      </p>\n\n      <div\n        ref={scrollRef}\n        className={fit ? \"w-full\" : \"w-full overflow-x-auto overscroll-x-contain pb-1\"}\n        onScroll={() => setTip(null)}\n      >\n        <div className={fit ? \"w-full\" : \"inline-block min-w-max\"}>\n          {showMonthLabels ? (\n            <div className=\"flex\" aria-hidden=\"true\">\n              {showWeekdayLabels ? <div className=\"shrink-0\" style={{ width: LABEL_WIDTH + gap }} /> : null}\n              <div className=\"relative h-5 flex-1\">\n                {model.months.map((m) => (\n                  <span\n                    key={m.col}\n                    className=\"absolute top-0 font-mono text-[10px] uppercase tracking-[0.1em] text-zinc-500 dark:text-zinc-400\"\n                    style={{ left: `calc(${m.col} * (100% + ${gap}px) / ${weeks})` }}\n                  >\n                    {m.label}\n                  </span>\n                ))}\n              </div>\n            </div>\n          ) : null}\n\n          <div\n            role=\"grid\"\n            aria-label={label}\n            aria-describedby={summaryId}\n            aria-readonly=\"true\"\n            className=\"flex flex-col\"\n            style={{ gap }}\n            onKeyDown={handleKeyDown}\n            onPointerLeave={() => setTip(null)}\n          >\n            {rows.map((row, r) => (\n              <div key={r} role=\"row\" className=\"flex items-center\" style={{ gap }}>\n                {showWeekdayLabels ? (\n                  <span\n                    aria-hidden=\"true\"\n                    className=\"shrink-0 font-mono text-[9px] uppercase leading-none tracking-[0.08em] text-zinc-500 dark:text-zinc-400\"\n                    style={{ width: LABEL_WIDTH }}\n                  >\n                    {weekdayRows.has(r) ? formats.weekday.format(new Date(row[0].day * DAY_MS)) : \"\"}\n                  </span>\n                ) : null}\n                {row.map((cell, c) => {\n                  if (!cell.valid) {\n                    return (\n                      <div\n                        key={c}\n                        role=\"gridcell\"\n                        aria-hidden=\"true\"\n                        className={fit ? \"aspect-square min-w-0 flex-1\" : \"shrink-0\"}\n                        style={cellStyle}\n                      />\n                    );\n                  }\n                  const isTabStop = focusCell.row === r && focusCell.col === c;\n                  return (\n                    <div\n                      key={c}\n                      ref={(el) => {\n                        const key = `${r}:${c}`;\n                        if (el) cellRefs.current.set(key, el);\n                        else cellRefs.current.delete(key);\n                      }}\n                      role=\"gridcell\"\n                      tabIndex={isTabStop ? 0 : -1}\n                      aria-label={describe(cell)}\n                      onFocus={(e) => {\n                        setActive({ row: r, col: c });\n                        showTip(r, c, e.currentTarget);\n                      }}\n                      onBlur={() => setTip(null)}\n                      onPointerEnter={(e) => {\n                        if (e.pointerType === \"mouse\") showTip(r, c, e.currentTarget);\n                      }}\n                      onPointerDown={(e) => showTip(r, c, e.currentTarget)}\n                      className={`outline-offset-1 transition-[box-shadow] duration-150 hover:shadow-[inset_0_0_0_1px_rgba(0,0,0,0.35)] focus-visible:outline-2 focus-visible:outline-zinc-900 dark:hover:shadow-[inset_0_0_0_1px_rgba(255,255,255,0.5)] dark:focus-visible:outline-white ${\n                        cell.level === 0 ? \"bg-zinc-900/[0.07] dark:bg-white/[0.07]\" : \"\"\n                      } ${fit ? \"aspect-square min-w-0 flex-1\" : \"shrink-0\"}`}\n                      style={{ ...cellStyle, backgroundColor: levelColor(cell.level) }}\n                    />\n                  );\n                })}\n              </div>\n            ))}\n          </div>\n        </div>\n      </div>\n\n      {showLegend ? (\n        <div className=\"mt-3 flex flex-wrap items-center justify-between gap-x-4 gap-y-2 font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-500 dark:text-zinc-400\">\n          <span aria-hidden=\"true\">\n            <span className=\"text-zinc-900 dark:text-zinc-100\">{formats.number.format(model.total)}</span> {plural} · {rangeText}\n          </span>\n          <span className=\"flex items-center gap-1.5\" aria-hidden=\"true\">\n            Less\n            {LEVEL_MIX.map((_, level) => (\n              <span\n                key={level}\n                className={`inline-block size-2.5 ${level === 0 ? \"bg-zinc-900/[0.07] dark:bg-white/[0.07]\" : \"\"}`}\n                style={{ borderRadius: Math.min(radius, 3), backgroundColor: levelColor(level) }}\n              />\n            ))}\n            More\n          </span>\n        </div>\n      ) : null}\n\n      {tip && tipCell?.valid ? (\n        <div\n          role=\"presentation\"\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-full whitespace-nowrap rounded-md border border-zinc-900/10 bg-white px-2.5 py-1.5 text-xs shadow-[0_8px_24px_-12px_rgba(0,0,0,0.35)] dark:border-white/10 dark:bg-zinc-900\"\n          style={{ left: tip.x, top: tip.y - 8 }}\n        >\n          <span className=\"font-medium tabular-nums\">\n            {tipCell.count === 0 ? \"No\" : formats.number.format(tipCell.count)} {tipCell.count === 1 ? unit : plural}\n          </span>\n          <span className=\"ml-2 font-mono text-[10px] uppercase tracking-[0.1em] text-zinc-500 dark:text-zinc-400\">\n            {formats.date.format(new Date(tipCell.day * DAY_MS))}\n          </span>\n          <span\n            className=\"absolute top-full size-2 -translate-x-1/2 -translate-y-1 rotate-45 border-r border-b border-zinc-900/10 bg-white dark:border-white/10 dark:bg-zinc-900\"\n            style={{ left: `calc(50% + ${tip.arrow}px)` }}\n          />\n        </div>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "data"
  ],
  "docs": "Documentation: https://wancraft.my/components/activity-heatmap"
}
