{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "streak-heatmap",
  "type": "registry:component",
  "title": "Streak Heatmap",
  "description": "A year of daily activity as a calendar heatmap: hover or arrow through any day, see which streak it belongs to, and point at a streak callout to light up its run.",
  "categories": [
    "component",
    "Numbers"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/devl/streak-heatmap.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { FlameIcon, StarIcon, TrophyIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type KeyboardEvent, type ReactNode, useEffect, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface StreakDay {\n  date: string;\n  count: number;\n}\n\nexport interface StreakHeatmapProps {\n  title?: string;\n  data?: StreakDay[];\n  endDate?: string;\n  unit?: [string, string];\n  color?: string;\n  weekStart?: 0 | 1;\n  locale?: string;\n  className?: string;\n}\n\nconst DAY = 86400000;\nconst GAP = 2;\nconst LABEL_W = 28;\nconst MONTH_H = 16;\nconst WEEKS = 53;\nconst MIX = [0, 30, 52, 76, 100];\n\nconst toUtc = (iso: string) => {\n  const [y, m, d] = iso.split(\"-\").map(Number);\n  return Date.UTC(y, m - 1, d);\n};\nconst toIso = (t: number) => new Date(t).toISOString().slice(0, 10);\n\nfunction mulberry(seed: number) {\n  return () => {\n    seed |= 0;\n    seed = (seed + 0x6d2b79f5) | 0;\n    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\nfunction sampleYear(end = \"2026-10-24\"): StreakDay[] {\n  const rand = mulberry(7);\n  const endT = toUtc(end);\n  const out: StreakDay[] = [];\n  for (let i = 370; i >= 0; i--) {\n    const t = endT - i * DAY;\n    const date = new Date(t);\n    const dow = date.getUTCDay();\n    const iso = toIso(t);\n    const weekend = dow === 0 || dow === 6;\n    const breakTime = iso >= \"2026-08-08\" && iso <= \"2026-08-23\";\n    const holidays = iso >= \"2025-12-22\" && iso <= \"2026-01-02\";\n    const springRun = iso >= \"2026-03-02\" && iso <= \"2026-04-11\";\n    const liveRun = i <= 22;\n    const season = Math.round((1 + 0.35 * Math.sin((i / 365) * Math.PI * 2 + 1.2)) * 100) / 100;\n    let active = rand() < (weekend ? 0.28 : 0.84);\n    if (breakTime || holidays) active = rand() < 0.05;\n    if (springRun || liveRun) active = true;\n    if (i === 23) active = false;\n    if (!active) continue;\n    const burst = rand() < 0.06 ? 2.4 : 1;\n    const count = Math.max(1, Math.round((weekend ? 2 : 5) * season * burst * (0.4 + rand() * 1.4)));\n    out.push({ date: iso, count });\n  }\n  return out;\n}\n\nconst defaultData = sampleYear();\n\ninterface Cell {\n  t: number;\n  iso: string;\n  count: number;\n  level: number;\n  col: number;\n  row: number;\n  future: boolean;\n}\n\ninterface Run {\n  start: number;\n  end: number;\n  length: number;\n}\n\nexport function StreakHeatmap({\n  title = \"Activity\",\n  data = defaultData,\n  endDate,\n  unit = [\"contribution\", \"contributions\"],\n  color = \"var(--color-emerald-500)\",\n  weekStart = 1,\n  locale = \"en-US\",\n  className,\n}: StreakHeatmapProps) {\n  const reduce = useReducedMotion();\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const [width, setWidth] = useState(560);\n  const [active, setActive] = useState<number | null>(null);\n  const [focus, setFocus] = useState<{ key: string; cells: Set<number> } | null>(null);\n  const [announce, setAnnounce] = useState(\"\");\n  const [scrollX, setScrollX] = useState(0);\n\n  useEffect(() => {\n    const el = scrollRef.current;\n    if (!el) return;\n    const ro = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));\n    ro.observe(el);\n    el.scrollLeft = el.scrollWidth;\n    return () => ro.disconnect();\n  }, []);\n\n  const model = useMemo(() => {\n    const counts = new Map(data.map((d) => [d.date, d.count]));\n    const last = endDate ?? data.reduce((m, d) => (d.date > m ? d.date : m), data[0]?.date ?? \"2026-10-24\");\n    const endT = toUtc(last);\n    const endDow = (new Date(endT).getUTCDay() - weekStart + 7) % 7;\n    const firstT = endT - (endDow + (WEEKS - 1) * 7) * DAY;\n    const sorted = [...data.filter((d) => d.count > 0).map((d) => d.count)].sort((a, b) => a - b);\n    const q = (p: number) => sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))] ?? 1;\n    const cuts = [q(0.25), q(0.55), q(0.85)];\n    const cells: Cell[] = [];\n    for (let i = 0; i < WEEKS * 7; i++) {\n      const t = firstT + i * DAY;\n      const iso = toIso(t);\n      const count = counts.get(iso) ?? 0;\n      const level = count <= 0 ? 0 : count <= cuts[0] ? 1 : count <= cuts[1] ? 2 : count <= cuts[2] ? 3 : 4;\n      cells.push({ t, iso, count, level, col: Math.floor(i / 7), row: i % 7, future: t > endT });\n    }\n    const endIdx = cells.findIndex((c) => c.t === endT);\n\n    const runs: Run[] = [];\n    const runOf = new Map<number, Run>();\n    let start = -1;\n    for (let i = 0; i <= endIdx + 1; i++) {\n      const on = i <= endIdx && cells[i].count > 0;\n      if (on && start < 0) start = i;\n      if (!on && start >= 0) {\n        const run = { start, end: i - 1, length: i - start };\n        runs.push(run);\n        for (let k = start; k < i; k++) runOf.set(k, run);\n        start = -1;\n      }\n    }\n    const longest = runs.reduce<Run | null>((best, r) => (!best || r.length > best.length ? r : best), null);\n    const tail = runs.at(-1);\n    const current = tail && (tail.end === endIdx || tail.end === endIdx - 1) ? tail : null;\n    const best = cells.slice(0, endIdx + 1).reduce((b, c, i) => (c.count > cells[b].count ? i : b), 0);\n    const total = cells.reduce((sum, c) => sum + (c.future ? 0 : c.count), 0);\n\n    const months: { col: number; label: string }[] = [];\n    for (let col = 0; col < WEEKS; col++) {\n      const firstOfMonth = cells.slice(col * 7, col * 7 + 7).find((c) => new Date(c.t).getUTCDate() === 1);\n      const atStart = col === 0 && new Date(cells[0].t).getUTCDate() <= 21;\n      if (firstOfMonth || atStart) {\n        const t = firstOfMonth?.t ?? cells[6].t;\n        if (months.length && col - months[months.length - 1].col < 3) {\n          if (months.length === 1 && !firstOfMonth) continue;\n          months.pop();\n        }\n        months.push({ col, label: new Date(t).toLocaleDateString(locale, { month: \"short\", timeZone: \"UTC\" }) });\n      }\n    }\n    return { cells, endIdx, runOf, longest, current, best, total, months, firstT, endT };\n  }, [data, endDate, weekStart, locale]);\n\n  const { cells, endIdx, runOf, longest, current, best, total, months } = model;\n  const cell = Math.max(8, Math.min(13, (width + GAP) / WEEKS - GAP));\n  const pitch = cell + GAP;\n  const svgW = WEEKS * pitch - GAP;\n  const svgH = MONTH_H + 7 * pitch - GAP;\n  const radius = Math.max(2, Math.round(cell / 4));\n\n  const fmtDate = (t: number, opts: Intl.DateTimeFormatOptions) =>\n    new Date(t).toLocaleDateString(locale, { timeZone: \"UTC\", ...opts });\n  const noun = (n: number) => (n === 1 ? unit[0] : unit[1]);\n  const num = new Intl.NumberFormat(locale);\n  const fill = (level: number) =>\n    level === 0\n      ? \"color-mix(in oklab, var(--color-foreground) 7%, var(--color-card))\"\n      : `color-mix(in oklab, ${color} ${MIX[level]}%, var(--color-card))`;\n\n  const range = (run: Run | null) => new Set(run ? Array.from({ length: run.length }, (_, k) => run.start + k) : []);\n  const runLabel = (run: Run | null) => {\n    if (!run) return \"No active run\";\n    const s = fmtDate(cells[run.start].t, { month: \"short\", day: \"numeric\" });\n    const e = run.end === endIdx ? \"today\" : fmtDate(cells[run.end].t, { month: \"short\", day: \"numeric\" });\n    return `${s} – ${e}`;\n  };\n\n  function describe(i: number) {\n    const c = cells[i];\n    return `${c.count ? num.format(c.count) : \"No\"} ${noun(c.count)} on ${fmtDate(c.t, { weekday: \"long\", month: \"long\", day: \"numeric\" })}`;\n  }\n\n  function move(i: number) {\n    const next = Math.max(0, Math.min(endIdx, i));\n    setActive(next);\n    setAnnounce(describe(next));\n    const el = scrollRef.current;\n    if (el) {\n      const x = cells[next].col * pitch;\n      if (x < el.scrollLeft) el.scrollLeft = x - pitch;\n      else if (x + pitch > el.scrollLeft + el.clientWidth) el.scrollLeft = x + pitch * 2 - el.clientWidth;\n    }\n  }\n\n  function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {\n    const cur = active ?? endIdx;\n    const map: Record<string, number> = {\n      ArrowRight: cur + 7,\n      ArrowLeft: cur - 7,\n      ArrowDown: cur + 1,\n      ArrowUp: cur - 1,\n      Home: 0,\n      End: endIdx,\n    };\n    if (e.key === \"Escape\") {\n      setActive(null);\n      return;\n    }\n    if (!(e.key in map)) return;\n    e.preventDefault();\n    move(map[e.key]);\n  }\n\n  const activeCell = active === null ? null : cells[active];\n  const activeRun = active === null ? undefined : runOf.get(active);\n  const tipX = activeCell ? activeCell.col * pitch + cell / 2 : 0;\n  const tipY = activeCell ? MONTH_H + activeCell.row * pitch : 0;\n  const visibleX = LABEL_W + tipX - scrollX;\n  const align = visibleX < 90 ? \"start\" : visibleX > LABEL_W + width - 90 ? \"end\" : \"center\";\n  const dayRows = weekStart === 1 ? [\"Mon\", \"\", \"Wed\", \"\", \"Fri\", \"\", \"\"] : [\"\", \"Mon\", \"\", \"Wed\", \"\", \"Fri\", \"\"];\n  const highlight = focus?.cells ?? null;\n\n  return (\n    <section\n      aria-label={title}\n      className={cn(\"@container w-full max-w-[620px] rounded-2xl border bg-card p-5 text-card-foreground shadow-xs/5\", className)}\n    >\n      <header className=\"flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1\">\n        <h3 className=\"font-medium text-sm\">{title}</h3>\n        <p className=\"text-muted-foreground text-xs tabular-nums\">\n          <span className=\"font-medium text-foreground\">{num.format(total)}</span> {noun(total)} in the last year\n        </p>\n      </header>\n\n      <div className=\"mt-4 grid grid-cols-3 gap-2 @max-[26rem]:gap-1.5\">\n        <Callout\n          active={focus?.key === \"current\"}\n          detail={runLabel(current)}\n          shortDetail={current ? `Since ${fmtDate(cells[current.start].t, { month: \"short\", day: \"numeric\" })}` : \"No run\"}\n          icon={<FlameIcon />}\n          label=\"Current streak\"\n          short=\"Streak\"\n          onFocusChange={(on) => setFocus(on ? { key: \"current\", cells: range(current) } : null)}\n          tone=\"text-orange-600 dark:text-orange-400\"\n          value={current ? `${current.length} days` : \"0 days\"}\n        />\n        <Callout\n          active={focus?.key === \"longest\"}\n          detail={runLabel(longest)}\n          shortDetail={\n            !longest\n              ? \"No run\"\n              : longest.end >= endIdx - 1\n                ? `Since ${fmtDate(cells[longest.start].t, { month: \"short\", day: \"numeric\" })}`\n                : `Ended ${fmtDate(cells[longest.end].t, { month: \"short\", day: \"numeric\" })}`\n          }\n          icon={<TrophyIcon />}\n          label=\"Longest streak\"\n          short=\"Longest\"\n          onFocusChange={(on) => setFocus(on ? { key: \"longest\", cells: range(longest) } : null)}\n          tone=\"text-amber-600 dark:text-amber-400\"\n          value={longest ? `${longest.length} days` : \"0 days\"}\n        />\n        <Callout\n          active={focus?.key === \"best\"}\n          detail={cells[best].count ? fmtDate(cells[best].t, { month: \"short\", day: \"numeric\" }) : \"No activity yet\"}\n          icon={<StarIcon />}\n          label=\"Best day\"\n          short=\"Best day\"\n          onFocusChange={(on) => setFocus(on ? { key: \"best\", cells: new Set([best]) } : null)}\n          tone=\"text-sky-600 dark:text-sky-400\"\n          value={cells[best].count ? num.format(cells[best].count) : \"–\"}\n        />\n      </div>\n\n      <div className=\"relative mt-4 flex\">\n        <svg aria-hidden className=\"block shrink-0\" height={svgH} width={LABEL_W}>\n          {dayRows.map((d, r) =>\n            d ? (\n              <text\n                className=\"fill-muted-foreground text-[10px]\"\n                dominantBaseline=\"central\"\n                key={d}\n                x={0}\n                y={MONTH_H + r * pitch + cell / 2}\n              >\n                {d}\n              </text>\n            ) : null,\n          )}\n        </svg>\n        <div\n          aria-label={`${title} calendar. Use arrow keys to move between days.`}\n          className=\"-m-[3px] min-w-0 flex-1 overflow-x-auto overscroll-x-contain rounded-md p-[3px] outline-none ring-offset-2 ring-offset-card [scrollbar-width:none] focus-visible:ring-2 focus-visible:ring-ring\"\n          onBlur={() => setActive(null)}\n          onFocus={(e) => e.target === e.currentTarget && active === null && move(endIdx)}\n          onKeyDown={onKeyDown}\n          onPointerLeave={() => setActive(null)}\n          onScroll={(e) => setScrollX(e.currentTarget.scrollLeft)}\n          ref={scrollRef}\n          role=\"application\"\n          tabIndex={0}\n        >\n          <svg aria-hidden className=\"block\" height={svgH} width={svgW}>\n            {months.map((m) => (\n              <text className=\"fill-muted-foreground text-[10px]\" key={`${m.col}-${m.label}`} x={m.col * pitch} y={10}>\n                {m.label}\n              </text>\n            ))}\n            {Array.from({ length: WEEKS }, (_, col) => (\n              <motion.g\n                animate={{ opacity: 1 }}\n                initial={{ opacity: 0 }}\n                key={col}\n                transition={{ duration: reduce ? 0 : 0.3, delay: reduce ? 0 : Math.min(0.45, col * 0.008), ease: \"easeOut\" }}\n              >\n                {cells.slice(col * 7, col * 7 + 7).map((c, r) => {\n                  const i = col * 7 + r;\n                  if (c.future) return null;\n                  const dim = highlight ? !highlight.has(i) : false;\n                  return (\n                    <rect\n                      className=\"transition-opacity duration-150 ease-out\"\n                      height={cell}\n                      key={c.iso}\n                      onPointerEnter={() => setActive(i)}\n                      opacity={dim ? 0.22 : 1}\n                      rx={radius}\n                      style={{ fill: fill(c.level) }}\n                      width={cell}\n                      x={col * pitch}\n                      y={MONTH_H + r * pitch}\n                    />\n                  );\n                })}\n              </motion.g>\n            ))}\n            {activeCell ? (\n              <rect\n                className=\"pointer-events-none fill-none stroke-foreground\"\n                height={cell + 2}\n                rx={radius + 1}\n                strokeWidth={1.5}\n                width={cell + 2}\n                x={tipX - cell / 2 - 1}\n                y={tipY - 1}\n              />\n            ) : null}\n          </svg>\n        </div>\n\n        <div\n          aria-hidden\n          className={cn(\n            \"pointer-events-none absolute inset-y-0 w-5 bg-linear-to-r from-card to-transparent transition-opacity duration-150\",\n            scrollX > 2 ? \"opacity-100\" : \"opacity-0\",\n          )}\n          style={{ left: LABEL_W }}\n        />\n        <AnimatePresence>\n          {activeCell ? (\n            <motion.div\n              animate={{ opacity: 1, scale: 1 }}\n              className={cn(\n                \"pointer-events-none absolute z-10 whitespace-nowrap rounded-lg border bg-popover px-2.5 py-1.5 text-popover-foreground text-xs shadow-md/5\",\n                align === \"center\" && \"-translate-x-1/2\",\n                align === \"end\" && \"-translate-x-full\",\n                \"-translate-y-full\",\n              )}\n              exit={{ opacity: 0, transition: { duration: reduce ? 0 : 0.1 } }}\n              initial={{ opacity: 0, scale: reduce ? 1 : 0.96 }}\n              style={{\n                left: visibleX + (align === \"start\" ? -cell / 2 - 4 : align === \"end\" ? cell / 2 + 4 : 0),\n                top: tipY - 8,\n                originX: align === \"start\" ? 0 : align === \"end\" ? 1 : 0.5,\n                originY: 1,\n              }}\n              transition={{ duration: reduce ? 0 : 0.14, ease: [0.23, 1, 0.32, 1] }}\n            >\n              <div className=\"tabular-nums\">\n                <span className=\"font-medium\">\n                  {activeCell.count ? num.format(activeCell.count) : \"No\"} {noun(activeCell.count)}\n                </span>\n                <span className=\"text-muted-foreground\">\n                  {\" \"}\n                  · {fmtDate(activeCell.t, { weekday: \"short\", month: \"short\", day: \"numeric\" })}\n                </span>\n              </div>\n              {activeRun && activeRun.length >= 3 && active !== null ? (\n                <div className=\"mt-0.5 text-muted-foreground\">\n                  Day {active - activeRun.start + 1} of a {activeRun.length}-day streak\n                </div>\n              ) : null}\n            </motion.div>\n          ) : null}\n        </AnimatePresence>\n        <span aria-live=\"polite\" className=\"sr-only\">\n          {announce}\n        </span>\n      </div>\n\n      <footer className=\"mt-3 flex items-center justify-between gap-3 text-muted-foreground text-xs\">\n        <span className=\"tabular-nums\">\n          {fmtDate(model.firstT, { month: \"short\", year: \"numeric\" })} – {fmtDate(model.endT, { month: \"short\", year: \"numeric\" })}\n        </span>\n        <div className=\"flex items-center gap-1.5\">\n          <span>Less</span>\n          <div className=\"flex gap-0.5\" onPointerLeave={() => setFocus(null)}>\n            {MIX.map((_, level) => (\n              <span\n                aria-hidden\n                className=\"size-2.5 rounded-[calc(var(--radius)*0.3)] transition-transform duration-150 ease-out hover:scale-125\"\n                key={level}\n                onPointerEnter={() =>\n                  setFocus({\n                    key: `level-${level}`,\n                    cells: new Set(cells.flatMap((c, i) => (c.level === level && !c.future ? [i] : []))),\n                  })\n                }\n                style={{ background: fill(level) }}\n              />\n            ))}\n          </div>\n          <span>More</span>\n        </div>\n      </footer>\n    </section>\n  );\n}\n\nfunction Callout({\n  label,\n  short,\n  value,\n  detail,\n  shortDetail,\n  icon,\n  tone,\n  active,\n  onFocusChange,\n}: {\n  label: string;\n  short: string;\n  value: string;\n  detail: string;\n  shortDetail?: string;\n  icon: ReactNode;\n  tone: string;\n  active: boolean;\n  onFocusChange: (on: boolean) => void;\n}) {\n  return (\n    <button\n      aria-label={`${label}: ${value}, ${detail}. Highlights it on the calendar.`}\n      className={cn(\n        \"flex min-w-0 cursor-default flex-col items-start gap-0.5 rounded-xl border border-transparent bg-muted/60 px-3 py-2.5 @max-[26rem]:px-2 text-left outline-none transition-[background-color,border-color,scale] duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97] dark:bg-muted\",\n        active && \"border-border bg-background dark:bg-accent\",\n      )}\n      onBlur={() => onFocusChange(false)}\n      onFocus={() => onFocusChange(true)}\n      onPointerEnter={() => onFocusChange(true)}\n      onPointerLeave={() => onFocusChange(false)}\n      type=\"button\"\n    >\n      <span className=\"flex items-center gap-1 text-muted-foreground text-xs\">\n        <span className={cn(\"[&_svg]:size-3.5\", tone)}>{icon}</span>\n        <span className=\"truncate @max-[26rem]:hidden\">{label}</span>\n        <span className=\"truncate @min-[26rem]:hidden\">{short}</span>\n      </span>\n      <span className=\"font-semibold text-base tabular-nums tracking-tight\">{value}</span>\n      <span className=\"w-full truncate text-muted-foreground text-xs tabular-nums @max-[26rem]:hidden\">{detail}</span>\n      <span className=\"w-full truncate text-muted-foreground text-xs tabular-nums @min-[26rem]:hidden\">\n        {shortDetail ?? detail}\n      </span>\n    </button>\n  );\n}\n"
    }
  ]
}