{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "github-activity",
  "title": "GitHub Activity",
  "description": "A contribution heatmap with a footer panel that expands over the grid to rank your top repositories.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "components/ui/github-activity.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  type Transition,\n} from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ContributionLevel = 0 | 1 | 2 | 3 | 4;\n\nexport type Contribution = {\n  date: string;\n  count: number;\n  level: ContributionLevel;\n};\n\nexport type RepoContribution = {\n  name: string;\n  count: number;\n  logo?: React.ReactNode;\n  href?: string;\n};\n\nconst DEFAULT_ACCENT = \"#39d353\";\nconst DEFAULT_CELL_SIZE = 11;\nconst DEFAULT_LABEL = \"Top contributions in:\";\nconst DEFAULT_MONTHS = 12;\nconst WEEKS_PER_MONTH = 365.25 / 12 / 7;\nconst STACK_LIMIT = 3;\nconst MIN_CARD_WIDTH = 320;\nconst MIN_LABEL_WEEKS = 3;\n// the p-4 on the card, both sides; the width math below has to add it back\nconst CARD_PADDING = 32;\n\nconst gapFor = (cellSize: number) => Math.max(2, Math.round(cellSize / 4));\n// never zero: weeks.slice(-0) would hand back the whole history instead of nothing\nconst weeksFor = (months: number) =>\n  Math.max(1, Math.ceil(months * WEEKS_PER_MONTH));\n\nconst useIsoLayoutEffect =\n  typeof window !== \"undefined\" ? React.useLayoutEffect : React.useEffect;\n\nconst EASE_OUT = [0.22, 1, 0.36, 1] as const;\nconst SPRING = { type: \"spring\", bounce: 0.2, duration: 0.62 } as const;\nconst HEADER_SPRING = { ...SPRING, bounce: 0.45 } as const;\nconst ROW_SPRING = { ...SPRING, bounce: 0.26, delay: 0.08 } as const;\nconst ROW_OFFSET = 16;\nconst CELL_FADE = { duration: 0.2, ease: EASE_OUT } as const;\nconst TOOLTIP_FADE = { duration: 0.14, ease: EASE_OUT } as const;\nconst TOOLTIP_EDGE = 8;\nconst COLUMN_STAGGER = 0.012;\nconst LABEL_BLUR = 6;\nconst LABEL_REVEAL = { duration: 0.45, ease: EASE_OUT } as const;\n\nconst LEVELS = [0, 1, 2, 3, 4] as const;\n\nconst MONTH_NAMES = [\n  \"Jan\",\n  \"Feb\",\n  \"Mar\",\n  \"Apr\",\n  \"May\",\n  \"Jun\",\n  \"Jul\",\n  \"Aug\",\n  \"Sep\",\n  \"Oct\",\n  \"Nov\",\n  \"Dec\",\n];\n\nfunction toMonthLabels(weeks: Contribution[][]) {\n  const labels: (string | null)[] = weeks.map(() => null);\n  const monthAt = (index: number) => weeks[index]?.[0]?.date.slice(5, 7);\n\n  let start = 0;\n  for (let i = 1; i <= weeks.length; i++) {\n    if (i < weeks.length && monthAt(i) === monthAt(start)) continue;\n    // a shorter run is narrower than the label itself, so it would sit under the next month\n    if (i - start >= MIN_LABEL_WEEKS) {\n      labels[start] = MONTH_NAMES[Number(monthAt(start)) - 1] ?? null;\n    }\n    start = i;\n  }\n\n  return labels;\n}\n\nconst LEVEL_OPACITY: Record<ContributionLevel, number> = {\n  0: 0,\n  1: 0.3,\n  2: 0.52,\n  3: 0.76,\n  4: 1,\n};\n\ntype LevelStyle = { backgroundColor: string; opacity: number };\n\ntype HoveredDay = { day: Contribution; x: number; y: number };\n\nconst DATE_FORMAT = new Intl.DateTimeFormat(\"en-US\", {\n  month: \"short\",\n  day: \"numeric\",\n  year: \"numeric\",\n});\n\nfunction describeDay({ count, date }: Contribution) {\n  const noun = count === 1 ? \"contribution\" : \"contributions\";\n  return `${count} ${noun} on ${DATE_FORMAT.format(new Date(`${date}T00:00:00`))}`;\n}\n\nconst CALENDAR_API = \"https://github-contributions-api.jogruber.de/v4\";\nconst EVENTS_API = \"https://api.github.com/users\";\n\ntype ApiDay = { date: string; count: number; level: number };\ntype PushEvent = {\n  type: string;\n  repo?: { name: string };\n  payload?: { commits?: unknown[] };\n};\n\nasync function fetchCalendar(login: string) {\n  const res = await fetch(`${CALENDAR_API}/${login}?y=last`);\n  if (!res.ok) return null;\n\n  const days: ApiDay[] = (await res.json())?.contributions ?? [];\n  if (!days.length) return null;\n\n  // columns are weeks, so the first day has to be a sunday or every column shears\n  const start = days.findIndex(\n    (day) => new Date(`${day.date}T00:00:00Z`).getUTCDay() === 0,\n  );\n\n  return days.slice(start < 0 ? 0 : start).map<Contribution>((day) => ({\n    date: day.date,\n    count: day.count,\n    level: Math.min(4, Math.max(0, day.level)) as ContributionLevel,\n  }));\n}\n\nasync function fetchRepos(login: string): Promise<RepoContribution[]> {\n  const res = await fetch(`${EVENTS_API}/${login}/events/public?per_page=100`);\n  if (!res.ok) return [];\n\n  const events: PushEvent[] = await res.json();\n  const counts = new Map<string, number>();\n\n  for (const event of events) {\n    if (event.type !== \"PushEvent\" || !event.repo) continue;\n    const commits = event.payload?.commits?.length ?? 1;\n    counts.set(event.repo.name, (counts.get(event.repo.name) ?? 0) + commits);\n  }\n\n  return [...counts.entries()]\n    .sort(([, a], [, b]) => b - a)\n    .slice(0, STACK_LIMIT)\n    .map(([fullName, count]) => {\n      const [owner, name] = fullName.split(\"/\");\n      return {\n        name,\n        count,\n        href: `https://github.com/${fullName}`,\n        // github has no repo logo, only an owner avatar, so own repos use the initial\n        logo:\n          owner.toLowerCase() === login.toLowerCase() ? undefined : (\n            // eslint-disable-next-line @next/next/no-img-element\n            <img src={`https://github.com/${owner}.png?size=64`} alt=\"\" />\n          ),\n      };\n    });\n}\n\nfunction useGitHubUser(login?: string) {\n  const [data, setData] = React.useState<{\n    contributions: Contribution[];\n    repos: RepoContribution[];\n  }>();\n\n  React.useEffect(() => {\n    if (!login) return;\n    let active = true;\n\n    Promise.all([fetchCalendar(login), fetchRepos(login)])\n      .then(([contributions, repos]) => {\n        if (active && contributions) setData({ contributions, repos });\n      })\n      .catch(() => {});\n\n    return () => {\n      active = false;\n    };\n  }, [login]);\n\n  return data;\n}\n\nfunction emptyDays(weeks: number): Contribution[] {\n  const today = new Date();\n  return Array.from({ length: weeks * 7 }, (_, i) => {\n    const date = new Date(today);\n    date.setDate(date.getDate() - (weeks * 7 - 1 - i));\n    return {\n      date: date.toISOString().slice(0, 10),\n      count: 0,\n      level: 0 as ContributionLevel,\n    };\n  });\n}\n\nfunction toScale(accent: string | string[]): LevelStyle[] {\n  if (typeof accent === \"string\") {\n    return LEVELS.map((level) => ({\n      backgroundColor: accent,\n      opacity: LEVEL_OPACITY[level],\n    }));\n  }\n\n  const colors = accent.length > 4 ? accent : [\"transparent\", ...accent];\n  return LEVELS.map((level) => {\n    const color = colors[level] ?? colors.at(-1) ?? \"transparent\";\n    return { backgroundColor: color, opacity: color === \"transparent\" ? 0 : 1 };\n  });\n}\n\nfunction toWeeks(contributions: Contribution[]) {\n  const weeks: Contribution[][] = [];\n  for (let i = 0; i < contributions.length; i += 7) {\n    weeks.push(contributions.slice(i, i + 7));\n  }\n  return weeks;\n}\n\nfunction useFittedColumns(cellSize: number, gap: number) {\n  const ref = React.useRef<HTMLDivElement>(null);\n  const [columns, setColumns] = React.useState<number>();\n\n  useIsoLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n\n    const measure = () =>\n      setColumns(\n        Math.max(1, Math.floor((el.clientWidth + gap) / (cellSize + gap))),\n      );\n\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [cellSize, gap]);\n\n  return [ref, columns] as const;\n}\n\nconst Tooltip = ({\n  hovered,\n  reduceMotion,\n}: {\n  hovered: HoveredDay;\n  reduceMotion: boolean | null;\n}) => {\n  const ref = React.useRef<HTMLDivElement>(null);\n  const [left, setLeft] = React.useState(hovered.x);\n\n  useIsoLayoutEffect(() => {\n    const half = (ref.current?.offsetWidth ?? 0) / 2;\n    const edge = TOOLTIP_EDGE + half;\n    setLeft(Math.min(Math.max(hovered.x, edge), window.innerWidth - edge));\n  }, [hovered]);\n\n  return createPortal(\n    <div\n      className=\"pointer-events-none fixed z-50\"\n      style={{\n        left,\n        top: hovered.y,\n        transform: \"translate(-50%, calc(-100% - 8px))\",\n      }}\n    >\n      <motion.div\n        ref={ref}\n        className=\"whitespace-nowrap rounded-lg bg-foreground px-2 py-1 text-[11px] font-medium text-background shadow-md\"\n        initial={reduceMotion ? false : { opacity: 0, scale: 0.94 }}\n        animate={{ opacity: 1, scale: 1 }}\n        exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.94 }}\n        transition={reduceMotion ? { duration: 0 } : TOOLTIP_FADE}\n      >\n        {describeDay(hovered.day)}\n      </motion.div>\n    </div>,\n    document.body,\n  );\n};\n\nconst ContributionGrid = ({\n  contributions,\n  scale,\n  cellSize,\n  months,\n  showMonths,\n  label,\n  reduceMotion,\n}: {\n  contributions: Contribution[];\n  scale: LevelStyle[];\n  cellSize: number;\n  months: number;\n  showMonths: boolean;\n  label: string;\n  reduceMotion: boolean | null;\n}) => {\n  const weeks = React.useMemo(() => toWeeks(contributions), [contributions]);\n  const gap = gapFor(cellSize);\n  const [ref, columns] = useFittedColumns(cellSize, gap);\n  const [hovered, setHovered] = React.useState<HoveredDay>();\n\n  const cap = Math.min(weeks.length, weeksFor(months));\n  const visible = weeks.slice(-Math.min(cap, columns ?? cap));\n  const sweepEnd = (visible.length - 1) * COLUMN_STAGGER + CELL_FADE.duration;\n\n  const hover = (day: Contribution) => (event: React.PointerEvent) => {\n    const cell = event.currentTarget.getBoundingClientRect();\n    setHovered({ day, x: cell.left + cell.width / 2, y: cell.top });\n  };\n\n  return (\n    <div\n      ref={ref}\n      data-slot=\"github-activity-grid\"\n      role=\"img\"\n      aria-label={label}\n      className=\"relative\"\n    >\n      {showMonths && (\n        <motion.div\n          className=\"flex justify-center\"\n          style={{ gap, marginBottom: gap }}\n          initial={\n            reduceMotion\n              ? false\n              : { opacity: 0, filter: `blur(${LABEL_BLUR}px)` }\n          }\n          animate={{ opacity: 1, filter: \"blur(0px)\" }}\n          transition={{\n            ...LABEL_REVEAL,\n            delay: reduceMotion ? 0 : sweepEnd,\n          }}\n        >\n          {toMonthLabels(visible).map((month, index) => (\n            <div\n              key={index}\n              className=\"relative h-3 shrink-0\"\n              style={{ width: cellSize }}\n            >\n              {month && (\n                <span className=\"absolute left-0 top-0 text-[10px] leading-none text-foreground/40\">\n                  {month}\n                </span>\n              )}\n            </div>\n          ))}\n        </motion.div>\n      )}\n\n      <div\n        className=\"flex justify-center overflow-hidden\"\n        style={{ gap }}\n        onPointerLeave={() => setHovered(undefined)}\n      >\n        {visible.map((week, weekIndex) => (\n          <div key={weekIndex} className=\"flex flex-col\" style={{ gap }}>\n            {week.map((day) => (\n              <motion.div\n                key={day.date}\n                onPointerEnter={hover(day)}\n                className=\"shrink-0 rounded-[3px] bg-foreground/[0.08]\"\n                style={{ width: cellSize, height: cellSize }}\n                initial={reduceMotion ? false : { opacity: 0, scale: 0.4 }}\n                animate={{ opacity: 1, scale: 1 }}\n                transition={{\n                  ...CELL_FADE,\n                  delay: reduceMotion ? 0 : weekIndex * COLUMN_STAGGER,\n                }}\n              >\n                <div\n                  className=\"h-full w-full rounded-[3px]\"\n                  style={scale[day.level] ?? scale[0]}\n                />\n              </motion.div>\n            ))}\n          </div>\n        ))}\n      </div>\n\n      <AnimatePresence>\n        {hovered && (\n          <Tooltip\n            key=\"tooltip\"\n            hovered={hovered}\n            reduceMotion={reduceMotion}\n          />\n        )}\n      </AnimatePresence>\n    </div>\n  );\n};\n\nconst Avatar = ({\n  repo,\n  layoutId,\n  transition,\n  className,\n}: {\n  repo: RepoContribution;\n  layoutId: string;\n  transition: Transition;\n  className?: string;\n}) => (\n  <motion.span\n    layoutId={layoutId}\n    transition={transition}\n    className={cn(\n      \"grid size-7 shrink-0 place-items-center overflow-hidden rounded-full bg-neutral-200 text-[11px] font-medium uppercase text-foreground/70 ring-2 ring-background dark:bg-neutral-800\",\n      \"[&_img]:size-full [&_img]:object-cover [&_svg]:size-full\",\n      className,\n    )}\n  >\n    {repo.logo ?? repo.name.charAt(0)}\n  </motion.span>\n);\n\nconst RepoRow = ({\n  repo,\n  layoutId,\n  transition,\n}: {\n  repo: RepoContribution;\n  layoutId: string;\n  transition: Transition;\n}) => {\n  const className =\n    \"flex items-center gap-3 rounded-xl mx-2 px-2 py-2 transition-colors hover:bg-foreground/5\";\n\n  const content = (\n    <>\n      <Avatar repo={repo} layoutId={layoutId} transition={transition} />\n      <span className=\"flex-1 truncate text-sm text-foreground\">\n        {repo.name}\n      </span>\n      <span className=\"text-sm tabular-nums text-foreground/70\">\n        {repo.count}\n      </span>\n    </>\n  );\n\n  return repo.href ? (\n    <a href={repo.href} target=\"_blank\" rel=\"noreferrer\" className={className}>\n      {content}\n    </a>\n  ) : (\n    <div className={className}>{content}</div>\n  );\n};\n\nconst Chevron = ({\n  open,\n  transition,\n}: {\n  open: boolean;\n  transition: Transition;\n}) => (\n  <motion.svg\n    viewBox=\"0 0 24 24\"\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeWidth=\"1.5\"\n    strokeLinecap=\"round\"\n    strokeLinejoin=\"round\"\n    aria-hidden\n    className=\"size-7 text-[#C4C9CC] dark:text-[#3E4346]\"\n    initial={false}\n    animate={{ rotate: open ? 180 : 0 }}\n    transition={transition}\n  >\n    <circle cx=\"12\" cy=\"12\" r=\"10\" />\n    <path d=\"m16 10-4 4-4-4\" />\n  </motion.svg>\n);\n\nexport type GitHubActivityProps = React.ComponentProps<\"div\"> & {\n  username?: string;\n  contributions?: Contribution[];\n  repos?: RepoContribution[];\n  year?: number;\n  accent?: string | string[];\n  cellSize?: number;\n  months?: number;\n  showMonths?: boolean;\n  label?: string;\n  defaultOpen?: boolean;\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n};\n\nconst GitHubActivity = ({\n  className,\n  username,\n  contributions: contributionsProp = [],\n  repos: reposProp = [],\n  year,\n  accent = DEFAULT_ACCENT,\n  cellSize = DEFAULT_CELL_SIZE,\n  months = DEFAULT_MONTHS,\n  showMonths = false,\n  label = DEFAULT_LABEL,\n  defaultOpen = false,\n  open: openProp,\n  onOpenChange,\n  style,\n  ...props\n}: GitHubActivityProps) => {\n  const reduceMotion = useReducedMotion();\n  const uid = React.useId();\n  const [openState, setOpenState] = React.useState(defaultOpen);\n\n  const open = openProp ?? openState;\n  const toggle = () => {\n    if (openProp === undefined) setOpenState(!open);\n    onOpenChange?.(!open);\n  };\n\n  const needsFetch = !contributionsProp.length || !reposProp.length;\n  const fetched = useGitHubUser(needsFetch ? username : undefined);\n  const placeholder = React.useMemo(\n    () => (username ? emptyDays(weeksFor(months)) : []),\n    [username, months],\n  );\n\n  const contributions = contributionsProp.length\n    ? contributionsProp\n    : (fetched?.contributions ?? placeholder);\n  const repos = reposProp.length ? reposProp : (fetched?.repos ?? []);\n\n  const scale = React.useMemo(() => toScale(accent), [accent]);\n  const transition = reduceMotion ? { duration: 0 } : SPRING;\n  const headerTransition = reduceMotion ? { duration: 0 } : HEADER_SPRING;\n  const rowTransition = reduceMotion ? { duration: 0 } : ROW_SPRING;\n\n  const kick = reduceMotion ? {} : { x: ROW_OFFSET, y: ROW_OFFSET };\n  const listMotion = {\n    initial: { opacity: 0, ...kick },\n    animate: { opacity: 1, x: 0, y: 0 },\n    exit: { opacity: 0, ...kick },\n  };\n\n  const total = React.useMemo(\n    () => contributions.reduce((sum, day) => sum + day.count, 0),\n    [contributions],\n  );\n\n  const parsedYear = Number(contributions.at(-1)?.date.slice(0, 4));\n  const displayYear = year ?? (Number.isFinite(parsedYear) ? parsedYear : null);\n  const heading = `${total} contributions${displayYear ? ` in ${displayYear}` : \"\"}`;\n\n  const gap = gapFor(cellSize);\n  const columns = Math.min(\n    Math.ceil(contributions.length / 7),\n    weeksFor(months),\n  );\n  const width = Math.max(\n    MIN_CARD_WIDTH,\n    columns * (cellSize + gap) - gap + CARD_PADDING,\n  );\n\n  return (\n    <div\n      data-slot=\"github-activity\"\n      className={cn(\n        \"relative max-w-full overflow-hidden rounded-[28px] bg-white p-4 dark:bg-black\",\n        repos.length > 0 && \"pb-[76px]\",\n        className,\n      )}\n      style={{ width, ...style }}\n      {...props}\n    >\n      <p className=\"mb-4 text-base font-medium text-foreground px-1.5\">\n        {heading}\n      </p>\n\n      <ContributionGrid\n        contributions={contributions}\n        scale={scale}\n        cellSize={cellSize}\n        months={months}\n        showMonths={showMonths}\n        label={heading}\n        reduceMotion={reduceMotion}\n      />\n\n      {repos.length > 0 && (\n        <motion.div\n          layout\n          id={`${uid}-panel`}\n          data-slot=\"github-activity-panel\"\n          data-state={open ? \"open\" : \"closed\"}\n          className={cn(\n            \"absolute inset-x-3 bottom-3 overflow-hidden bg-card/90 backdrop-blur-xl\",\n            open && \"top-3\",\n          )}\n          style={{ borderRadius: 18 }}\n          transition={transition}\n        >\n          <motion.div\n            layout=\"position\"\n            transition={headerTransition}\n            className=\"flex items-center justify-between gap-3 py-3 px-4\"\n          >\n            <span className=\"truncate text-sm text-foreground\">{label}</span>\n\n            <div className=\"flex items-center gap-3\">\n              {!open && (\n                <div className=\"flex items-center\">\n                  {repos.slice(0, STACK_LIMIT).map((repo, index) => (\n                    <Avatar\n                      key={index}\n                      repo={repo}\n                      layoutId={`${uid}-${index}`}\n                      transition={transition}\n                      className=\"-ml-2 first:ml-0\"\n                    />\n                  ))}\n                </div>\n              )}\n\n              <button\n                type=\"button\"\n                onClick={toggle}\n                aria-expanded={open}\n                aria-controls={`${uid}-panel`}\n                aria-label={\n                  open ? \"Hide top repositories\" : \"Show top repositories\"\n                }\n                className=\"grid size-7 shrink-0 place-items-center rounded-full bg-card\"\n              >\n                <Chevron open={open} transition={transition} />\n              </button>\n            </div>\n          </motion.div>\n\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {open && (\n              <motion.ul\n                key=\"list\"\n                layout=\"position\"\n                {...listMotion}\n                transition={rowTransition}\n                className=\"px-0.5 pb-1\"\n              >\n                {repos.map((repo, index) => (\n                  <li key={index}>\n                    <RepoRow\n                      repo={repo}\n                      layoutId={`${uid}-${index}`}\n                      transition={transition}\n                    />\n                  </li>\n                ))}\n              </motion.ul>\n            )}\n          </AnimatePresence>\n        </motion.div>\n      )}\n    </div>\n  );\n};\n\nexport { GitHubActivity };\nexport default GitHubActivity;\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}