{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "activity-heatmap",
  "title": "Activity Heatmap",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Um quadrado por dia mostra o ritmo de meses de trabalho, com bolha que desliza entre os dias e teclado de calendário.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "@visant/motion"
  ],
  "files": [
    {
      "path": "registry/visant/ui/activity-heatmap.tsx",
      "content": "\"use client\";\n\n/* Adaptado de Activity Heatmap — https://uiarc.dev/components/activity-heatmap — Copyright (c) 2026 Elia Kuratli, licença MIT (texto integral em ATTRIBUTIONS.md do @visant/ui). */\n\nimport * as React from \"react\";\nimport { motion, useInView, useReducedMotion } from \"motion/react\";\n\nimport { DUR, EASE_OUT, INSTANT } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Activity Heatmap — um quadrado por dia, semanas em colunas.\n *\n * Pra ritmo ao longo de um período longo: entregas, treinos, posts, pedidos.\n * Serve quando sequência e semana parada importam mais que comparação exata\n * (aí é gráfico de barras). Não é seletor de data (aí é calendário).\n *\n * ── As decisões que não são óbvias ─────────────────────────────────────────\n *\n * 1. **Os níveis são a marca misturada no `--muted`.** Quatro tons de\n *    `color-mix` entre o vazio e `var(--brand)`, então a grade veste qualquer\n *    cliente e os dois temas sem paleta própria. A cor é fundo, nunca texto.\n * 2. **Os limites saem dos quartos do dia mais cheio**, a não ser que venham\n *    por prop. Escala fixa faz uma conta pequena parecer vazia e uma grande\n *    parecer saturada.\n * 3. **O estado de repouso é a grade inteira.** A onda de entrada (diagonal,\n *    teto de 420 ms somados) só esconde as células depois de o JS estar vivo e\n *    a grade ainda fora de vista. Sem script, o dado está lá.\n * 4. **Uma bolha só pra grade toda.** Ela salta pro lugar quando abre e\n *    desliza numa mola entre as células depois disso. 370 tooltips seriam 370\n *    nós pra nada.\n * 5. **Teclado anda como no calendário:** seta pra cima e pra baixo é um dia,\n *    pros lados é uma semana, Home e End vão às pontas, Enter e Espaço\n *    escolhem. A grade é uma parada de Tab só (tabindex itinerante).\n * 6. **No estreito a grade rola**, e abre já na semana mais recente: o fim é o\n *    que se quer ver primeiro.\n * 7. **Datas em UTC.** `YYYY-MM-DD` é um dia de calendário, não um instante; em\n *    fuso local o dia escorrega pro vizinho e servidor e cliente discordam.\n */\n\nexport interface ActivityDay {\n  /** Dia como `YYYY-MM-DD`. */\n  date: string;\n  count: number;\n}\n\nexport interface ActivityHeatmapProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  /** Um item por dia, do mais antigo ao mais novo. Dia faltando conta zero. */\n  days: ActivityDay[];\n  /** Nome acessível da grade, como \"Entregas em 2026\". */\n  label: string;\n  /** Fecha o resumo: \"1.284 entregas em {period}\". */\n  period: string;\n  /** Substantivo da contagem, no singular e no plural. */\n  unit?: { one: string; other: string };\n  /** Teto dos níveis 1 a 3; acima do último é nível 4. Default: quartos do dia mais cheio. */\n  thresholds?: [number, number, number];\n  /** 0 = semana começa no domingo; 1 = na segunda. */\n  weekStartsOn?: 0 | 1;\n  /** Dia marcado com o anel de seleção. */\n  selectedDate?: string | null;\n  /** Clique, Enter ou Espaço num dia. */\n  onSelectDate?: (date: string) => void;\n  /** Controles ao lado do resumo, como um seletor de período. */\n  actions?: React.ReactNode;\n  locale?: string;\n  /** Lado do quadrado, em px. */\n  cellSize?: number;\n}\n\nconst DIA = 86_400_000;\nconst ONDA = 420;\nconst GAP = 3;\nconst ROTULO = 28;\nconst REGISTROS = { one: \"registro\", other: \"registros\" };\nconst NIVEL = [\n  \"var(--muted)\",\n  \"color-mix(in oklab, var(--brand) 28%, var(--muted))\",\n  \"color-mix(in oklab, var(--brand) 52%, var(--muted))\",\n  \"color-mix(in oklab, var(--brand) 76%, var(--muted))\",\n  \"var(--brand)\",\n];\n\nconst utc = (iso: string) => Date.parse(`${iso}T00:00:00Z`);\nconst iso = (t: number) => new Date(t).toISOString().slice(0, 10);\nconst semPonto = (s: string) => s.replace(/\\.$/, \"\");\n\nfunction montar(days: ActivityDay[], inicioSemana: 0 | 1, limites: [number, number, number] | undefined, locale: string) {\n  const datas = days.map((d) => utc(d.date)).filter(Number.isFinite);\n  const inicio = datas.length ? Math.min(...datas) : 0;\n  const total = datas.length ? Math.round((Math.max(...datas) - inicio) / DIA) + 1 : 0;\n  const contagens = new Array<number>(total).fill(0);\n  for (const d of days) {\n    const i = Math.round((utc(d.date) - inicio) / DIA);\n    if (i >= 0 && i < total) contagens[i]! += Math.max(0, d.count);\n  }\n  const maior = contagens.reduce((a, b) => Math.max(a, b), 0);\n  const tetos =\n    limites ??\n    ([Math.max(1, Math.ceil(maior * 0.25)), Math.max(2, Math.ceil(maior * 0.5)), Math.max(3, Math.ceil(maior * 0.75))] as [\n      number,\n      number,\n      number,\n    ]);\n  const niveis = contagens.map((c) => (c <= 0 ? 0 : c <= tetos[0] ? 1 : c <= tetos[1] ? 2 : c <= tetos[2] ? 3 : 4));\n  const recuo = (new Date(inicio).getUTCDay() - inicioSemana + 7) % 7;\n  const semanas = Math.ceil((recuo + total) / 7);\n  const nomeMes = new Intl.DateTimeFormat(locale, { month: \"short\", timeZone: \"UTC\" });\n  const meses: { col: number; nome: string }[] = [];\n  for (let i = 0; i < total; i++) {\n    const d = new Date(inicio + i * DIA);\n    if (i === 0 || d.getUTCDate() === 1) meses.push({ col: Math.floor((recuo + i) / 7), nome: semPonto(nomeMes.format(d)) });\n  }\n  // Mês parcial no começo só leva rótulo se houver espaço antes do próximo.\n  if (meses.length > 1 && meses[1]!.col - meses[0]!.col < 3) meses.shift();\n  return { inicio, total, contagens, niveis, recuo, semanas, meses, soma: contagens.reduce((a, b) => a + b, 0) };\n}\n\nconst nada = () => () => {};\n\nexport function ActivityHeatmap({\n  days,\n  label,\n  period,\n  unit = REGISTROS,\n  thresholds,\n  weekStartsOn = 0,\n  selectedDate = null,\n  onSelectDate,\n  actions,\n  locale = \"pt-BR\",\n  cellSize = 12,\n  className,\n  style,\n  ...props\n}: ActivityHeatmapProps) {\n  const hidratado = React.useSyncExternalStore(nada, () => true, () => false);\n  const reduzir = !!useReducedMotion() && hidratado;\n  const raiz = React.useRef<HTMLDivElement>(null);\n  const rolagem = React.useRef<HTMLDivElement>(null);\n  const grade = React.useRef<HTMLDivElement>(null);\n  const emVista = useInView(grade, { once: true, amount: 0.35 });\n\n  const m = React.useMemo(\n    () => montar(days, weekStartsOn, thresholds, locale),\n    [days, weekStartsOn, thresholds, locale],\n  );\n  const fmt = React.useMemo(\n    () => ({\n      longo: new Intl.DateTimeFormat(locale, { weekday: \"long\", day: \"numeric\", month: \"long\", year: \"numeric\", timeZone: \"UTC\" }),\n      curto: new Intl.DateTimeFormat(locale, { weekday: \"short\", day: \"numeric\", month: \"short\", year: \"numeric\", timeZone: \"UTC\" }),\n      semana: new Intl.DateTimeFormat(locale, { weekday: \"short\", timeZone: \"UTC\" }),\n      num: new Intl.NumberFormat(locale),\n    }),\n    [locale],\n  );\n  const nome = (n: number) => (n === 1 ? unit.one : unit.other);\n\n  // Onda de entrada: \"parado\" é a grade cheia; só esconde depois do JS vivo e fora de vista.\n  const [onda, setOnda] = React.useState<\"parado\" | \"escondido\" | \"entrando\">(\"parado\");\n  React.useLayoutEffect(() => {\n    if (!reduzir && !emVista) setOnda(\"escondido\");\n    // Só na montagem: depois disso quem manda é o observer.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n  React.useEffect(() => {\n    if (onda !== \"escondido\" || !(emVista || reduzir)) return;\n    const raf = requestAnimationFrame(() => setOnda(reduzir ? \"parado\" : \"entrando\"));\n    return () => cancelAnimationFrame(raf);\n  }, [onda, emVista, reduzir]);\n  React.useEffect(() => {\n    if (onda !== \"entrando\") return;\n    const t = window.setTimeout(() => setOnda(\"parado\"), ONDA + 400);\n    return () => window.clearTimeout(t);\n  }, [onda]);\n\n  // Abre na semana mais recente.\n  React.useLayoutEffect(() => {\n    const el = rolagem.current;\n    if (el) el.scrollLeft = el.scrollWidth;\n  }, [m.semanas]);\n\n  const iSel = selectedDate ? Math.round((utc(selectedDate) - m.inicio) / DIA) : -1;\n  const temSel = iSel >= 0 && iSel < m.total;\n  const [foco, setFoco] = React.useState<number | null>(null);\n  const iTab = foco !== null && foco < m.total ? foco : temSel ? iSel : m.total - 1;\n\n  // A bolha: salta quando abre, desliza entre células depois.\n  const [dica, setDica] = React.useState<{ i: number; x: number; y: number } | null>(null);\n  const [aberta, setAberta] = React.useState(false);\n  const estavaAberta = React.useRef(false);\n  const esconder = React.useRef(0);\n  React.useEffect(() => {\n    estavaAberta.current = aberta;\n  });\n  React.useEffect(() => () => window.clearTimeout(esconder.current), []);\n\n  const mostrar = (cel: HTMLElement, i: number) => {\n    const no = raiz.current;\n    const r = no?.getBoundingClientRect();\n    const c = cel.getBoundingClientRect();\n    if (!no || !r) return;\n    window.clearTimeout(esconder.current);\n    // Os retângulos são de TELA; a bolha é posicionada em px LOCAIS. Dentro de um ancestral com\n    // `transform: scale` os dois divergem, e a bolha caía longe do dia. A escala desfaz a diferença.\n    const escala = no.offsetWidth ? r.width / no.offsetWidth : 1;\n    const largura = r.width / escala;\n    // A bolha não passa da borda da peça.\n    const x = Math.min(Math.max((c.left - r.left + c.width / 2) / escala, 72), largura - 72);\n    setDica({ i, x, y: (c.top - r.top) / escala });\n    setAberta(true);\n  };\n  const fecharEm = (ms: number) => {\n    window.clearTimeout(esconder.current);\n    esconder.current = window.setTimeout(() => setAberta(false), ms);\n  };\n\n  const alvo = (el: EventTarget | null) => {\n    const cel = (el as Element | null)?.closest?.<HTMLElement>(\"[data-i]\");\n    return cel && grade.current?.contains(cel) ? { cel, i: Number(cel.dataset.i) } : null;\n  };\n  const focar = (i: number) => grade.current?.querySelector<HTMLElement>(`[data-i=\"${i}\"]`)?.focus();\n\n  function onKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {\n    const h = alvo(e.target);\n    if (!h) return;\n    const passos: Record<string, number> = {\n      ArrowUp: h.i - 1,\n      ArrowDown: h.i + 1,\n      ArrowLeft: h.i - 7,\n      ArrowRight: h.i + 7,\n      Home: 0,\n      End: m.total - 1,\n    };\n    if (e.key in passos) {\n      e.preventDefault();\n      focar(Math.min(Math.max(passos[e.key]!, 0), m.total - 1));\n    } else if (e.key === \"Enter\" || e.key === \" \") {\n      e.preventDefault();\n      onSelectDate?.(iso(m.inicio + h.i * DIA));\n    } else if (e.key === \"Escape\" && aberta) {\n      e.preventDefault();\n      setAberta(false);\n    }\n  }\n\n  const diag = Math.max(1, m.semanas - 1 + 6);\n  const passoOnda = Math.min(12, ONDA / diag);\n  const passo = cellSize + GAP;\n\n  const linhas = Array.from({ length: 7 }, (_, lin) => (\n    <div key={lin} role=\"row\" className=\"flex\" style={{ gap: GAP }}>\n      {Array.from({ length: m.semanas }, (_, col) => {\n        const i = col * 7 + lin - m.recuo;\n        const atraso = onda === \"entrando\" ? `${Math.round((col + lin) * passoOnda)}ms` : \"0ms\";\n        const visual: React.CSSProperties = {\n          width: cellSize,\n          height: cellSize,\n          opacity: onda === \"escondido\" ? 0 : 1,\n          transform: onda === \"escondido\" ? \"scale(0.5)\" : \"none\",\n          transition:\n            onda === \"escondido\"\n              ? \"none\"\n              : `opacity 360ms var(--ease-out) ${atraso}, transform var(--dur-slow) var(--ease-out) ${atraso}, background-color var(--dur-slow) var(--ease-out)`,\n        };\n        if (i < 0 || i >= m.total) return <span key={col} aria-hidden=\"true\" className=\"flex-none\" style={visual} />;\n        const n = m.contagens[i]!;\n        const data = new Date(m.inicio + i * DIA);\n        const sel = temSel && i === iSel;\n        return (\n          <span\n            key={col}\n            role=\"gridcell\"\n            data-i={i}\n            tabIndex={i === iTab ? 0 : -1}\n            aria-selected={onSelectDate ? sel : undefined}\n            aria-label={`${n ? fmt.num.format(n) : \"Sem\"} ${nome(n)}, ${fmt.longo.format(data)}`}\n            onClick={() => onSelectDate?.(iso(data.getTime()))}\n            className={cn(\n              \"block flex-none rounded-[min(var(--r-control),3px)] outline-offset-1 focus-visible:outline-none focus-visible:[box-shadow:var(--focus)]\",\n              sel && \"outline outline-[1.5px] outline-foreground\",\n              onSelectDate && \"cursor-pointer\",\n            )}\n            style={{ ...visual, background: NIVEL[m.niveis[i]!] }}\n          />\n        );\n      })}\n    </div>\n  ));\n\n  const diasDaSemana = Array.from({ length: 7 }, (_, lin) => {\n    const d = (lin + weekStartsOn) % 7;\n    // 4 de janeiro de 1970 foi domingo. Só os dias ímpares levam rótulo, como no papel.\n    return d % 2 === 1 ? semPonto(fmt.semana.format(new Date((3 + d) * DIA))) : \"\";\n  });\n\n  const resumo = `${fmt.num.format(m.soma)} ${nome(m.soma)} em ${period}`;\n  const dn = dica ? m.contagens[dica.i]! : 0;\n  const deslizar = aberta && estavaAberta.current && !reduzir;\n\n  return (\n    <div ref={raiz} className={cn(\"relative grid min-w-0 gap-3 text-foreground\", className)} style={style} {...props}>\n      <div className=\"flex min-w-0 flex-wrap items-center justify-between gap-x-4 gap-y-2\">\n        <p className=\"m-0 text-corpo tabular-nums text-muted-foreground\">\n          <span className=\"font-medium text-foreground\">\n            {fmt.num.format(m.soma)} {nome(m.soma)}\n          </span>{\" \"}\n          em {period}\n          <span className=\"sr-only\" role=\"status\">\n            {resumo}\n          </span>\n        </p>\n        {actions && <div className=\"flex flex-none items-center\">{actions}</div>}\n      </div>\n\n      <div ref={rolagem} className=\"-m-1.5 overflow-x-auto overflow-y-hidden overscroll-x-contain p-1.5\" onScroll={() => aberta && setAberta(false)}>\n        <div\n          className=\"grid w-max\"\n          style={{ gridTemplateColumns: `${ROTULO}px auto`, columnGap: GAP, rowGap: 6 }}\n        >\n          <div className=\"relative col-start-2 row-start-1 h-4 text-apoio leading-4 text-muted-foreground\" aria-hidden=\"true\">\n            {m.meses.map((mes) => (\n              <span key={`${mes.col}-${mes.nome}`} className=\"absolute top-0 whitespace-nowrap\" style={{ left: mes.col * passo }}>\n                {mes.nome}\n              </span>\n            ))}\n          </div>\n          <div\n            className=\"col-start-1 row-start-2 grid text-micro text-muted-foreground\"\n            style={{ gridTemplateRows: `repeat(7, ${cellSize}px)`, rowGap: GAP }}\n            aria-hidden=\"true\"\n          >\n            {diasDaSemana.map((t, lin) => (\n              <span key={lin} className=\"flex items-center whitespace-nowrap leading-none\">\n                {t}\n              </span>\n            ))}\n          </div>\n          <div\n            ref={grade}\n            role=\"grid\"\n            aria-label={label}\n            aria-readonly=\"true\"\n            className=\"col-start-2 row-start-2 grid outline-none\"\n            style={{ gap: GAP }}\n            onPointerOver={(e) => {\n              const h = alvo(e.target);\n              if (h) mostrar(h.cel, h.i);\n            }}\n            onPointerLeave={(e) => fecharEm(e.pointerType === \"touch\" ? 2400 : 110)}\n            onFocus={(e) => {\n              const h = alvo(e.target);\n              if (!h) return;\n              setFoco(h.i);\n              mostrar(h.cel, h.i);\n            }}\n            onBlur={(e) => {\n              if (!grade.current?.contains(e.relatedTarget as Node | null)) fecharEm(0);\n            }}\n            onKeyDown={onKeyDown}\n          >\n            {linhas}\n          </div>\n        </div>\n      </div>\n\n      <div className=\"flex items-center justify-end gap-2 text-apoio text-muted-foreground\" aria-hidden=\"true\">\n        <span>Menos</span>\n        <span className=\"flex gap-[3px]\">\n          {NIVEL.map((cor, n) => (\n            <span key={n} className=\"size-[11px] rounded-[min(var(--r-control),3px)]\" style={{ background: cor }} />\n          ))}\n        </span>\n        <span>Mais</span>\n      </div>\n\n      <motion.div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute left-0 top-0 z-10 size-0\"\n        initial={false}\n        animate={{ x: dica?.x ?? 0, y: dica?.y ?? 0 }}\n        transition={deslizar ? { type: \"spring\", visualDuration: 0.26, bounce: 0.12 } : INSTANT}\n      >\n        <motion.div\n          className=\"absolute bottom-2 left-0 grid whitespace-nowrap rounded-[var(--r-control)] bg-foreground px-3 py-1.5 text-background [box-shadow:var(--e-raised)]\"\n          initial={false}\n          animate={aberta && dica ? { opacity: 1, scale: 1 } : { opacity: 0, scale: reduzir ? 1 : 0.96 }}\n          transition={{ duration: aberta ? DUR.fast : DUR.press, ease: EASE_OUT }}\n          style={{ originX: 0.5, originY: 1, x: \"-50%\" }}\n        >\n          {dica && (\n            <>\n              <span className=\"text-corpo font-medium tabular-nums leading-snug\">\n                {dn ? `${fmt.num.format(dn)} ${nome(dn)}` : `Sem ${unit.other}`}\n              </span>\n              <span className=\"text-apoio leading-snug opacity-70\">{fmt.curto.format(new Date(m.inicio + dica.i * DIA))}</span>\n            </>\n          )}\n        </motion.div>\n      </motion.div>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Mostrar ritmo diário de atividade ao longo de meses, como entregas ou hábitos, em perfil ou dashboard",
      "evite": "Mistura de categorias no tempo: streamgraph; um valor só de cota, gauge"
    }
  },
  "categories": [
    "conteudo",
    "dados",
    "calendario",
    "heatmap",
    "ritmo",
    "grafico"
  ],
  "type": "registry:ui"
}