{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "contribution-graph",
  "type": "registry:ui",
  "registryDependencies": [],
  "dependencies": ["date-fns"],
  "devDependencies": [],
  "files": [
    {
      "path": "contribution-graph.tsx",
      "content": "\"use client\";\n\nimport type { Day as WeekDay } from \"date-fns\";\nimport {\n  differenceInCalendarDays,\n  eachDayOfInterval,\n  formatISO,\n  getDay,\n  getMonth,\n  getYear,\n  nextDay,\n  parseISO,\n  subDays,\n  subWeeks,\n} from \"date-fns\";\nimport {\n  type CSSProperties,\n  createContext,\n  type FocusEvent,\n  Fragment,\n  type HTMLAttributes,\n  type MouseEvent,\n  type ReactNode,\n  useContext,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cn } from \"@/lib/utils\";\n\nexport type Activity = {\n  date: string;\n  count: number;\n  level: number;\n};\n\ntype Week = Array<Activity | undefined>;\n\nexport type Labels = {\n  months?: string[];\n  weekdays?: string[];\n  totalCount?: string;\n  legend?: {\n    less?: string;\n    more?: string;\n  };\n};\n\ntype MonthLabel = {\n  weekIndex: number;\n  label: string;\n};\n\nconst DEFAULT_MONTH_LABELS = [\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\nconst DEFAULT_LABELS: Labels = {\n  months: DEFAULT_MONTH_LABELS,\n  weekdays: [\"Sun\", \"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\"],\n  totalCount: \"{{count}} activities in {{year}}\",\n  legend: {\n    less: \"Less\",\n    more: \"More\",\n  },\n};\n\n/** Rolling-window label used when data comes from a GitHub username. */\nconst ROLLING_TOTAL_LABEL = \"{{count}} contributions in the last year\";\n\n/** GitHub-style green level ramp, shared by blocks and the legend. */\nconst LEVEL_CLASSES = cn(\n  'data-[level=\"0\"]:fill-muted',\n  'data-[level=\"1\"]:fill-emerald-200 dark:data-[level=\"1\"]:fill-emerald-900',\n  'data-[level=\"2\"]:fill-emerald-400 dark:data-[level=\"2\"]:fill-emerald-700',\n  'data-[level=\"3\"]:fill-emerald-600 dark:data-[level=\"3\"]:fill-emerald-500',\n  'data-[level=\"4\"]:fill-emerald-800 dark:data-[level=\"4\"]:fill-emerald-300'\n);\n\n/** Entrance timing: the muted grid fades in as a canvas, then colored blocks\n *  light up in rounds by activity level — lightest greens first, darkest\n *  last — so the year reads as charging up. */\nconst LEVEL_REVEAL_BASE = 220;\nconst LEVEL_REVEAL_STEP = 170;\nconst LEVEL_REVEAL_JITTER = 110;\n\n/** Small deterministic per-block offset so each level's round shimmers in\n *  instead of snapping on as one frame. */\nconst revealJitter = (weekIndex: number, dayIndex: number) =>\n  Math.round(\n    ((((weekIndex * 7 + dayIndex) * 137) % 97) / 97) * LEVEL_REVEAL_JITTER\n  );\n\nconst ENTRANCE_KEYFRAMES = `@keyframes iconiq-cg-fade {\n  from { opacity: 0; }\n  to { opacity: 1; }\n}\n@keyframes iconiq-cg-pop {\n  0% { opacity: 0; transform: scale(0.4); }\n  65% { opacity: 1; transform: scale(1.12); }\n  100% { opacity: 1; transform: scale(1); }\n}`;\n\nconst CONTRIBUTIONS_API = \"https://github-contributions-api.jogruber.de\";\n\ntype ContributionApiResponse = {\n  total: Record<string, number>;\n  contributions: Activity[];\n};\n\ntype FetchedContributions = {\n  contributions: Activity[];\n  total: number;\n};\n\n/** One in-flight/settled request per username, so remounts replay the\n *  entrance without refetching. */\nconst contributionsCache = new Map<string, Promise<FetchedContributions>>();\n\nconst fetchContributions = (\n  username: string\n): Promise<FetchedContributions> => {\n  const cached = contributionsCache.get(username);\n\n  if (cached) {\n    return cached;\n  }\n\n  const url = new URL(\n    `/v4/${encodeURIComponent(username)}?y=last`,\n    CONTRIBUTIONS_API\n  );\n\n  const request = fetch(url).then(async (response) => {\n    if (!response.ok) {\n      throw new Error(\n        `GitHub contributions request failed (${response.status}).`\n      );\n    }\n\n    const data = (await response.json()) as ContributionApiResponse;\n    const total =\n      data.total.lastYear ??\n      data.contributions.reduce((sum, activity) => sum + activity.count, 0);\n\n    return { contributions: data.contributions, total };\n  });\n\n  request.catch(() => contributionsCache.delete(username));\n  contributionsCache.set(username, request);\n\n  return request;\n};\n\ntype FetchStatus = \"idle\" | \"loading\" | \"success\" | \"error\";\n\ntype FetchState = FetchedContributions & {\n  status: FetchStatus;\n};\n\nconst IDLE_FETCH_STATE: FetchState = {\n  status: \"idle\",\n  contributions: [],\n  total: 0,\n};\n\nconst useGitHubContributions = (username?: string): FetchState => {\n  const [state, setState] = useState<FetchState>(IDLE_FETCH_STATE);\n\n  useEffect(() => {\n    if (!username) {\n      setState(IDLE_FETCH_STATE);\n      return;\n    }\n\n    let cancelled = false;\n    setState({ status: \"loading\", contributions: [], total: 0 });\n\n    fetchContributions(username)\n      .then(({ contributions, total }) => {\n        if (!cancelled) {\n          setState({ status: \"success\", contributions, total });\n        }\n      })\n      .catch(() => {\n        if (!cancelled) {\n          setState({ status: \"error\", contributions: [], total: 0 });\n        }\n      });\n\n    return () => {\n      cancelled = true;\n    };\n  }, [username]);\n\n  return state;\n};\n\n/** Level-0 stand-in for the last 52 weeks so the grid keeps its exact\n *  footprint (and shimmers) while a username fetch is in flight. */\nconst createPlaceholderData = (): Activity[] => {\n  const today = new Date();\n\n  return eachDayOfInterval({ start: subDays(today, 7 * 52), end: today }).map(\n    (day) => ({\n      date: formatISO(day, { representation: \"date\" }),\n      count: 0,\n      level: 0,\n    })\n  );\n};\n\ntype ContributionGraphContextType = {\n  data: Activity[];\n  weeks: Week[];\n  animated: boolean;\n  blockMargin: number;\n  blockRadius: number;\n  blockSize: number;\n  contentKey: string;\n  fontSize: number;\n  labels: Labels;\n  labelHeight: number;\n  loading: boolean;\n  maxLevel: number;\n  totalCount: number;\n  weekStart: WeekDay;\n  year: number;\n  width: number;\n  height: number;\n};\n\nconst ContributionGraphContext =\n  createContext<ContributionGraphContextType | null>(null);\n\nconst useContributionGraph = () => {\n  const context = useContext(ContributionGraphContext);\n\n  if (!context) {\n    throw new Error(\n      \"ContributionGraph components must be used within a ContributionGraph\"\n    );\n  }\n\n  return context;\n};\n\ntype BlockTooltipPayload = {\n  activity: Activity;\n  x: number;\n  y: number;\n  content?: ReactNode;\n};\n\ntype BlockTooltipApi = {\n  show: (payload: BlockTooltipPayload) => void;\n  hide: () => void;\n};\n\nconst BlockTooltipApiContext = createContext<BlockTooltipApi | null>(null);\nconst BlockTooltipStateContext = createContext<BlockTooltipPayload | null>(\n  null\n);\n\nconst useBlockTooltipApi = () => useContext(BlockTooltipApiContext);\n\nconst usePrefersReducedMotion = () => {\n  const [reduced, setReduced] = useState(false);\n\n  useEffect(() => {\n    const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    setReduced(query.matches);\n\n    const onChange = (event: MediaQueryListEvent) => setReduced(event.matches);\n    query.addEventListener(\"change\", onChange);\n    return () => query.removeEventListener(\"change\", onChange);\n  }, []);\n\n  return reduced;\n};\n\nconst formatCommitLabel = (count: number) =>\n  `${count} commit${count === 1 ? \"\" : \"s\"}`;\n\nconst formatActivityDate = (date: string) =>\n  new Intl.DateTimeFormat(\"en\", {\n    day: \"numeric\",\n    month: \"short\",\n    year: \"numeric\",\n  }).format(parseISO(date));\n\n/** One shared floating tooltip for the whole grid. Positioning SVG cells with\n *  per-block Radix roots glitches on fast hover; this tracks the active cell\n *  and slides to it instead. */\nconst ContributionGraphBlockTooltip = () => {\n  const tooltip = useContext(BlockTooltipStateContext);\n  const prefersReducedMotion = usePrefersReducedMotion();\n  const [mounted, setMounted] = useState(false);\n  const [exitPayload, setExitPayload] = useState<BlockTooltipPayload | null>(\n    null\n  );\n  const [visible, setVisible] = useState(false);\n\n  useEffect(() => {\n    setMounted(true);\n  }, []);\n\n  useEffect(() => {\n    if (tooltip) {\n      setExitPayload(tooltip);\n    }\n  }, [tooltip]);\n\n  const isOpen = tooltip !== null;\n\n  useEffect(() => {\n    if (isOpen) {\n      const frame = requestAnimationFrame(() => setVisible(true));\n      return () => cancelAnimationFrame(frame);\n    }\n\n    setVisible(false);\n    const timeout = window.setTimeout(() => setExitPayload(null), 160);\n    return () => window.clearTimeout(timeout);\n  }, [isOpen]);\n\n  const payload = tooltip ?? exitPayload;\n\n  if (!(mounted && payload)) {\n    return null;\n  }\n\n  const scale = prefersReducedMotion || visible ? 1 : 0.96;\n\n  return createPortal(\n    <div\n      aria-hidden\n      className=\"pointer-events-none fixed z-50\"\n      style={{\n        left: payload.x,\n        top: payload.y,\n        opacity: visible ? 1 : 0,\n        transform: `translate(-50%, calc(-100% - 8px)) scale(${scale})`,\n        transitionProperty: prefersReducedMotion\n          ? \"opacity\"\n          : \"left, top, opacity, transform\",\n        transitionDuration: prefersReducedMotion\n          ? \"120ms\"\n          : \"140ms, 140ms, 160ms, 200ms\",\n        transitionTimingFunction: prefersReducedMotion\n          ? \"ease\"\n          : \"cubic-bezier(0.22, 1, 0.36, 1), cubic-bezier(0.22, 1, 0.36, 1), ease, cubic-bezier(0.34, 1.56, 0.64, 1)\",\n      }}\n    >\n      <div className=\"relative rounded-lg border border-white/10 bg-zinc-950 px-2.5 py-1 text-white shadow-[0_8px_26px_-8px_rgba(0,0,0,0.55)] dark:border-black/10 dark:bg-zinc-100 dark:text-zinc-950\">\n        {payload.content ?? (\n          <div className=\"flex items-baseline gap-1.5 whitespace-nowrap text-[11px] leading-none\">\n            <span className=\"font-semibold\">\n              {formatCommitLabel(payload.activity.count)}\n            </span>\n            <span className=\"text-zinc-400 dark:text-zinc-600\">\n              on {formatActivityDate(payload.activity.date)}\n            </span>\n          </div>\n        )}\n        <span\n          aria-hidden\n          className=\"absolute top-full left-1/2 -mt-px -translate-x-1/2 border-x-[5px] border-x-transparent border-t-[6px] border-t-zinc-950 dark:border-t-zinc-100\"\n        />\n      </div>\n    </div>,\n    document.body\n  );\n};\n\nconst ContributionGraphTooltipProvider = ({\n  children,\n}: {\n  children: ReactNode;\n}) => {\n  const [tooltip, setTooltip] = useState<BlockTooltipPayload | null>(null);\n  const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  const api = useMemo<BlockTooltipApi>(\n    () => ({\n      show: (payload) => {\n        if (hideTimeoutRef.current) {\n          clearTimeout(hideTimeoutRef.current);\n          hideTimeoutRef.current = null;\n        }\n        setTooltip(payload);\n      },\n      hide: () => {\n        if (hideTimeoutRef.current) {\n          clearTimeout(hideTimeoutRef.current);\n        }\n        // Brief delay so moving between adjacent cells doesn't flicker.\n        hideTimeoutRef.current = setTimeout(() => {\n          setTooltip(null);\n          hideTimeoutRef.current = null;\n        }, 40);\n      },\n    }),\n    []\n  );\n\n  useEffect(\n    () => () => {\n      if (hideTimeoutRef.current) {\n        clearTimeout(hideTimeoutRef.current);\n      }\n    },\n    []\n  );\n\n  // The tooltip is positioned in viewport space from a hover-time measurement,\n  // so any scroll (page or the calendar's own horizontal scroller) or resize\n  // would leave it stranded. Dismiss it immediately instead of tracking.\n  useEffect(() => {\n    const dismiss = () => {\n      if (hideTimeoutRef.current) {\n        clearTimeout(hideTimeoutRef.current);\n        hideTimeoutRef.current = null;\n      }\n      setTooltip(null);\n    };\n\n    window.addEventListener(\"scroll\", dismiss, true);\n    window.addEventListener(\"resize\", dismiss);\n    return () => {\n      window.removeEventListener(\"scroll\", dismiss, true);\n      window.removeEventListener(\"resize\", dismiss);\n    };\n  }, []);\n\n  return (\n    <BlockTooltipApiContext.Provider value={api}>\n      <BlockTooltipStateContext.Provider value={tooltip}>\n        {children}\n        <ContributionGraphBlockTooltip />\n      </BlockTooltipStateContext.Provider>\n    </BlockTooltipApiContext.Provider>\n  );\n};\n\nconst fillHoles = (activities: Activity[]): Activity[] => {\n  if (activities.length === 0) {\n    return [];\n  }\n\n  // Sort activities by date to ensure correct date range\n  const sortedActivities = [...activities].sort((a, b) =>\n    a.date.localeCompare(b.date)\n  );\n\n  const calendar = new Map<string, Activity>(\n    activities.map((a) => [a.date, a])\n  );\n\n  const firstActivity = sortedActivities[0] as Activity;\n  const lastActivity = sortedActivities.at(-1);\n\n  if (!lastActivity) {\n    return [];\n  }\n\n  return eachDayOfInterval({\n    start: parseISO(firstActivity.date),\n    end: parseISO(lastActivity.date),\n  }).map((day) => {\n    const date = formatISO(day, { representation: \"date\" });\n\n    if (calendar.has(date)) {\n      return calendar.get(date) as Activity;\n    }\n\n    return {\n      date,\n      count: 0,\n      level: 0,\n    };\n  });\n};\n\nconst groupByWeeks = (\n  activities: Activity[],\n  weekStart: WeekDay = 0\n): Week[] => {\n  if (activities.length === 0) {\n    return [];\n  }\n\n  const normalizedActivities = fillHoles(activities);\n  const firstActivity = normalizedActivities[0] as Activity;\n  const firstDate = parseISO(firstActivity.date);\n  const firstCalendarDate =\n    getDay(firstDate) === weekStart\n      ? firstDate\n      : subWeeks(nextDay(firstDate, weekStart), 1);\n\n  const paddedActivities = [\n    ...(new Array(differenceInCalendarDays(firstDate, firstCalendarDate)).fill(\n      undefined\n    ) as Activity[]),\n    ...normalizedActivities,\n  ];\n\n  const numberOfWeeks = Math.ceil(paddedActivities.length / 7);\n\n  return new Array(numberOfWeeks)\n    .fill(undefined)\n    .map((_, weekIndex) =>\n      paddedActivities.slice(weekIndex * 7, weekIndex * 7 + 7)\n    );\n};\n\nconst getMonthLabels = (\n  weeks: Week[],\n  monthNames: string[] = DEFAULT_MONTH_LABELS\n): MonthLabel[] => {\n  return weeks\n    .reduce<MonthLabel[]>((labels, week, weekIndex) => {\n      const firstActivity = week.find((activity) => activity !== undefined);\n\n      if (!firstActivity) {\n        throw new Error(\n          `Unexpected error: Week ${weekIndex + 1} is empty: [${week}].`\n        );\n      }\n\n      const month = monthNames[getMonth(parseISO(firstActivity.date))];\n\n      if (!month) {\n        const monthName = new Date(firstActivity.date).toLocaleString(\"en-US\", {\n          month: \"short\",\n        });\n        throw new Error(\n          `Unexpected error: undefined month label for ${monthName}.`\n        );\n      }\n\n      const prevLabel = labels.at(-1);\n\n      if (weekIndex === 0 || !prevLabel || prevLabel.label !== month) {\n        return labels.concat({ weekIndex, label: month });\n      }\n\n      return labels;\n    }, [])\n    .filter(({ weekIndex }, index, labels) => {\n      const minWeeks = 3;\n\n      if (index === 0) {\n        return labels[1] && labels[1].weekIndex - weekIndex >= minWeeks;\n      }\n\n      if (index === labels.length - 1) {\n        return weeks.slice(weekIndex).length >= minWeeks;\n      }\n\n      return true;\n    });\n};\n\nexport type ContributionGraphProps = HTMLAttributes<HTMLDivElement> & {\n  data?: Activity[];\n  username?: string;\n  animated?: boolean;\n  blockMargin?: number;\n  blockRadius?: number;\n  blockSize?: number;\n  fontSize?: number;\n  labels?: Labels;\n  maxLevel?: number;\n  style?: CSSProperties;\n  totalCount?: number;\n  weekStart?: WeekDay;\n  children: ReactNode;\n  className?: string;\n};\n\nexport const ContributionGraph = ({\n  data,\n  username = undefined,\n  animated = true,\n  blockMargin = 4,\n  blockRadius = 2,\n  blockSize = 12,\n  fontSize = 14,\n  labels: labelsProp = undefined,\n  maxLevel: maxLevelProp = 4,\n  style = {},\n  totalCount: totalCountProp = undefined,\n  weekStart = 0,\n  className,\n  children,\n  ...props\n}: ContributionGraphProps) => {\n  const maxLevel = Math.max(1, maxLevelProp);\n  const hasStaticData = Boolean(data && data.length > 0);\n  const fetched = useGitHubContributions(hasStaticData ? undefined : username);\n  const loading = fetched.status === \"loading\";\n\n  const resolvedData = useMemo(() => {\n    if (hasStaticData) {\n      return data as Activity[];\n    }\n\n    if (fetched.status === \"success\") {\n      return fetched.contributions;\n    }\n\n    if (fetched.status === \"loading\") {\n      return createPlaceholderData();\n    }\n\n    return [];\n  }, [data, fetched, hasStaticData]);\n\n  const weeks = useMemo(\n    () => groupByWeeks(resolvedData, weekStart),\n    [resolvedData, weekStart]\n  );\n  const LABEL_MARGIN = 8;\n\n  const labels = { ...DEFAULT_LABELS, ...labelsProp };\n\n  if (username && !hasStaticData && !labelsProp?.totalCount) {\n    labels.totalCount = ROLLING_TOTAL_LABEL;\n  }\n\n  const labelHeight = fontSize + LABEL_MARGIN;\n\n  const year =\n    resolvedData.length > 0\n      ? getYear(parseISO((resolvedData.at(-1) as Activity).date))\n      : new Date().getFullYear();\n\n  let totalCount = resolvedData.reduce(\n    (sum, activity) => sum + activity.count,\n    0\n  );\n\n  if (typeof totalCountProp === \"number\") {\n    totalCount = totalCountProp;\n  } else if (fetched.status === \"success\") {\n    totalCount = fetched.total;\n  }\n\n  const width = weeks.length * (blockSize + blockMargin) - blockMargin;\n  const height = labelHeight + (blockSize + blockMargin) * 7 - blockMargin;\n\n  // Remounts the calendar whenever the underlying dataset changes, so the\n  // wave entrance replays when a fetch lands or the username swaps.\n  const contentKey = `${username ?? \"static\"}-${fetched.status}`;\n\n  if (fetched.status === \"error\") {\n    return (\n      <div\n        className={cn(\n          \"flex w-max max-w-full items-center gap-2 rounded-lg border border-border border-dashed px-4 py-3 text-muted-foreground text-sm\",\n          className\n        )}\n        style={{ fontSize, ...style }}\n        {...props}\n      >\n        Unable to load contributions for @{username}.\n      </div>\n    );\n  }\n\n  if (resolvedData.length === 0) {\n    return null;\n  }\n\n  return (\n    <ContributionGraphContext.Provider\n      value={{\n        data: resolvedData,\n        weeks,\n        animated,\n        blockMargin,\n        blockRadius,\n        blockSize,\n        contentKey,\n        fontSize,\n        labels,\n        labelHeight,\n        loading,\n        maxLevel,\n        totalCount,\n        weekStart,\n        year,\n        width,\n        height,\n      }}\n    >\n      <div\n        className={cn(\"flex w-max max-w-full flex-col gap-2\", className)}\n        style={{ fontSize, ...style }}\n        {...props}\n      >\n        {animated ? <style>{ENTRANCE_KEYFRAMES}</style> : null}\n        <ContributionGraphTooltipProvider>\n          {children}\n        </ContributionGraphTooltipProvider>\n      </div>\n    </ContributionGraphContext.Provider>\n  );\n};\n\nexport type ContributionGraphBlockProps = HTMLAttributes<SVGRectElement> & {\n  activity: Activity;\n  dayIndex: number;\n  showTooltip?: boolean;\n  weekIndex: number;\n};\n\nexport const ContributionGraphBlock = ({\n  activity,\n  dayIndex,\n  showTooltip = true,\n  weekIndex,\n  className,\n  style,\n  children,\n  ...props\n}: ContributionGraphBlockProps) => {\n  const {\n    animated,\n    blockSize,\n    blockMargin,\n    blockRadius,\n    labelHeight,\n    loading,\n    maxLevel,\n  } = useContributionGraph();\n  const tooltipApi = useBlockTooltipApi();\n\n  // If tooltips are turned off (or the cell unmounts) while its bubble is open,\n  // there's no pointer-leave to close it — dismiss to be safe.\n  useEffect(() => {\n    if (showTooltip) {\n      return;\n    }\n\n    tooltipApi?.hide();\n  }, [showTooltip, tooltipApi]);\n\n  if (activity.level < 0 || activity.level > maxLevel) {\n    throw new RangeError(\n      `Provided activity level ${activity.level} for ${activity.date} is out of range. It must be between 0 and ${maxLevel}.`\n    );\n  }\n\n  // Level-0 cells fade in together as the canvas; colored cells join in\n  // rounds by level, each round offset by a small per-block jitter.\n  const entranceDelay =\n    activity.level === 0\n      ? 0\n      : LEVEL_REVEAL_BASE +\n        (activity.level - 1) * LEVEL_REVEAL_STEP +\n        revealJitter(weekIndex, dayIndex);\n  // Negative delays start the shimmer mid-cycle, so loading reads as a\n  // scattered flicker instead of every block pulsing in unison.\n  const animationDelay = loading\n    ? `${-revealJitter(weekIndex, dayIndex) * 10}ms`\n    : `${entranceDelay}ms`;\n  const showEntrance = animated && !loading;\n\n  const revealTooltip = (\n    event: MouseEvent<SVGRectElement> | FocusEvent<SVGRectElement>\n  ) => {\n    if (!(showTooltip && tooltipApi) || loading) {\n      return;\n    }\n\n    const rect = event.currentTarget.getBoundingClientRect();\n    tooltipApi.show({\n      activity,\n      x: rect.left + rect.width / 2,\n      y: rect.top,\n      content: children,\n    });\n  };\n\n  return (\n    <rect\n      aria-label={`${formatCommitLabel(activity.count)} on ${activity.date}`}\n      className={cn(\n        \"origin-center [transform-box:fill-box]\",\n        LEVEL_CLASSES,\n        loading\n          ? \"animate-pulse\"\n          : \"transition-transform duration-200 ease-out hover:scale-125\",\n        showEntrance &&\n          (activity.level === 0\n            ? \"animate-[iconiq-cg-fade_0.4s_ease-out_backwards]\"\n            : \"animate-[iconiq-cg-pop_0.45s_cubic-bezier(0.22,1,0.36,1)_backwards]\"),\n        \"motion-reduce:animate-none\",\n        className\n      )}\n      data-count={activity.count}\n      data-date={activity.date}\n      data-level={activity.level}\n      height={blockSize}\n      onBlur={showTooltip ? () => tooltipApi?.hide() : undefined}\n      onFocus={showTooltip ? revealTooltip : undefined}\n      onMouseEnter={showTooltip ? revealTooltip : undefined}\n      onMouseLeave={showTooltip ? () => tooltipApi?.hide() : undefined}\n      rx={blockRadius}\n      ry={blockRadius}\n      style={{\n        ...(showEntrance || loading ? { animationDelay } : {}),\n        ...style,\n      }}\n      width={blockSize}\n      x={(blockSize + blockMargin) * weekIndex}\n      y={labelHeight + (blockSize + blockMargin) * dayIndex}\n      {...props}\n    />\n  );\n};\n\nexport type ContributionGraphCalendarProps = Omit<\n  HTMLAttributes<HTMLDivElement>,\n  \"children\"\n> & {\n  hideMonthLabels?: boolean;\n  className?: string;\n  children: (props: {\n    activity: Activity;\n    dayIndex: number;\n    weekIndex: number;\n  }) => ReactNode;\n};\n\nexport const ContributionGraphCalendar = ({\n  hideMonthLabels = false,\n  className,\n  children,\n  ...props\n}: ContributionGraphCalendarProps) => {\n  const {\n    weeks,\n    width,\n    height,\n    blockSize,\n    blockMargin,\n    contentKey,\n    labels,\n    loading,\n  } = useContributionGraph();\n\n  const monthLabels = useMemo(\n    () => getMonthLabels(weeks, labels.months),\n    [weeks, labels.months]\n  );\n\n  const scrollToLatest = (container: HTMLDivElement | null) => {\n    if (!container) {\n      return;\n    }\n\n    const scrollToEnd = () => {\n      container.scrollLeft = container.scrollWidth - container.clientWidth;\n    };\n\n    scrollToEnd();\n    requestAnimationFrame(scrollToEnd);\n  };\n\n  return (\n    <div\n      className={cn(\n        \"-m-1 max-w-full overflow-x-auto overflow-y-hidden p-1\",\n        \"[&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border hover:[&::-webkit-scrollbar-thumb]:bg-muted-foreground/40 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar]:h-1\",\n        className\n      )}\n      // The padding (offset by the negative margin) keeps hover-scaled edge\n      // blocks inside the clip boundary instead of being cut off.\n      key={contentKey}\n      ref={scrollToLatest}\n      {...props}\n    >\n      <svg\n        aria-busy={loading}\n        aria-label=\"Contribution graph\"\n        className=\"block overflow-visible\"\n        height={height}\n        key={contentKey}\n        viewBox={`0 0 ${width} ${height}`}\n        width={width}\n      >\n        {!hideMonthLabels && (\n          <g className=\"fill-current\">\n            {monthLabels.map(({ label, weekIndex }) => (\n              <text\n                dominantBaseline=\"hanging\"\n                key={weekIndex}\n                x={(blockSize + blockMargin) * weekIndex}\n              >\n                {label}\n              </text>\n            ))}\n          </g>\n        )}\n        {weeks.map((week, weekIndex) =>\n          week.map((activity, dayIndex) => {\n            if (!activity) {\n              return null;\n            }\n\n            return (\n              <Fragment key={`${weekIndex}-${dayIndex}`}>\n                {children({ activity, dayIndex, weekIndex })}\n              </Fragment>\n            );\n          })\n        )}\n      </svg>\n    </div>\n  );\n};\n\nexport type ContributionGraphFooterProps = HTMLAttributes<HTMLDivElement>;\n\nexport const ContributionGraphFooter = ({\n  className,\n  ...props\n}: ContributionGraphFooterProps) => (\n  <div\n    className={cn(\n      \"flex flex-wrap gap-1 whitespace-nowrap sm:gap-x-4\",\n      className\n    )}\n    {...props}\n  />\n);\n\nexport type ContributionGraphTotalCountProps = Omit<\n  HTMLAttributes<HTMLDivElement>,\n  \"children\"\n> & {\n  children?: (props: { totalCount: number; year: number }) => ReactNode;\n};\n\nexport const ContributionGraphTotalCount = ({\n  className,\n  children,\n  ...props\n}: ContributionGraphTotalCountProps) => {\n  const { totalCount, year, labels, loading } = useContributionGraph();\n\n  if (loading) {\n    return (\n      <div\n        className={cn(\"animate-pulse text-muted-foreground\", className)}\n        {...props}\n      >\n        Loading contributions…\n      </div>\n    );\n  }\n\n  if (children) {\n    return <>{children({ totalCount, year })}</>;\n  }\n\n  return (\n    <div className={cn(\"text-muted-foreground\", className)} {...props}>\n      {labels.totalCount\n        ? labels.totalCount\n            .replace(\"{{count}}\", String(totalCount))\n            .replace(\"{{year}}\", String(year))\n        : `${totalCount} activities in ${year}`}\n    </div>\n  );\n};\n\nexport type ContributionGraphLegendProps = Omit<\n  HTMLAttributes<HTMLDivElement>,\n  \"children\"\n> & {\n  children?: (props: { level: number }) => ReactNode;\n};\n\nexport const ContributionGraphLegend = ({\n  className,\n  children,\n  ...props\n}: ContributionGraphLegendProps) => {\n  const { labels, maxLevel, blockSize, blockRadius } = useContributionGraph();\n\n  return (\n    <div\n      className={cn(\"ml-auto flex items-center gap-[3px]\", className)}\n      {...props}\n    >\n      <span className=\"mr-1 text-muted-foreground\">\n        {labels.legend?.less || \"Less\"}\n      </span>\n      {new Array(maxLevel + 1).fill(undefined).map((_, level) =>\n        children ? (\n          <Fragment key={level}>{children({ level })}</Fragment>\n        ) : (\n          <svg\n            aria-label={`${level} contributions`}\n            height={blockSize}\n            key={level}\n            width={blockSize}\n          >\n            <rect\n              className={cn(\"stroke-[1px] stroke-border\", LEVEL_CLASSES)}\n              data-level={level}\n              height={blockSize}\n              rx={blockRadius}\n              ry={blockRadius}\n              width={blockSize}\n            />\n          </svg>\n        )\n      )}\n      <span className=\"ml-1 text-muted-foreground\">\n        {labels.legend?.more || \"More\"}\n      </span>\n    </div>\n  );\n};\n",
      "type": "registry:ui"
    }
  ],
  "title": "Contribution Graph",
  "description": "GitHub-style contribution calendar with compound calendar, block, total-count, and legend parts — pass raw Activity data or just a GitHub username and it fetches and caches the last year of contributions itself, shimmers a skeleton grid while loading, then fades the muted grid in and lights the greens up level by level — lightest to darkest — with springy pops, plus custom per-day tooltips and reduced-motion aware behavior."
}
