{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "contribution-graph",
  "title": "Contribution Graph",
  "author": "ncdai <dai@chanhdai.com>",
  "description": "A GitHub-style contribution graph component that displays activity levels over time.",
  "dependencies": [
    "date-fns"
  ],
  "files": [
    {
      "path": "src/registry/components/contribution-graph/contribution-graph.tsx",
      "content": "// Credit: https://www.kibo-ui.com/components/contribution-graph\n\n\"use client\"\n\nimport {\n  createContext,\n  Fragment,\n  useContext,\n  useMemo,\n  type CSSProperties,\n  type HTMLAttributes,\n  type ReactNode,\n} from \"react\"\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  subWeeks,\n} from \"date-fns\"\n\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\nconst THEME = cn(\n  'data-[level=\"0\"]:fill-muted-foreground/5',\n  'data-[level=\"1\"]:fill-muted-foreground/20',\n  'data-[level=\"2\"]:fill-muted-foreground/40',\n  'data-[level=\"3\"]:fill-muted-foreground/60',\n  'data-[level=\"4\"]:fill-muted-foreground/80'\n)\n\ntype ContributionGraphContextType = {\n  data: Activity[]\n  weeks: Week[]\n  blockMargin: number\n  blockRadius: number\n  blockSize: number\n  fontSize: number\n  labels: Labels\n  labelHeight: number\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\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>(activities.map((a) => [a.date, a]))\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  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  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  ...props\n}: ContributionGraphProps) => {\n  const maxLevel = Math.max(1, maxLevelProp)\n  const weeks = useMemo(() => groupByWeeks(data, weekStart), [data, weekStart])\n  const LABEL_MARGIN = 8\n\n  const labels = { ...DEFAULT_LABELS, ...labelsProp }\n  const labelHeight = fontSize + LABEL_MARGIN\n\n  const year =\n    data.length > 0 ? getYear(parseISO(data[0].date)) : new Date().getFullYear()\n\n  const totalCount =\n    typeof totalCountProp === \"number\"\n      ? totalCountProp\n      : data.reduce((sum, activity) => sum + activity.count, 0)\n\n  const width = weeks.length * (blockSize + blockMargin) - blockMargin\n  const height = labelHeight + (blockSize + blockMargin) * 7 - blockMargin\n\n  if (data.length === 0) {\n    return null\n  }\n\n  return (\n    <ContributionGraphContext.Provider\n      value={{\n        data,\n        weeks,\n        blockMargin,\n        blockRadius,\n        blockSize,\n        fontSize,\n        labels,\n        labelHeight,\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    </ContributionGraphContext.Provider>\n  )\n}\n\nexport type ContributionGraphBlockProps = HTMLAttributes<SVGRectElement> & {\n  activity: Activity\n  dayIndex: number\n  weekIndex: number\n}\n\nexport const ContributionGraphBlock = ({\n  activity,\n  dayIndex,\n  weekIndex,\n  className,\n  ...props\n}: ContributionGraphBlockProps) => {\n  const { blockSize, blockMargin, blockRadius, labelHeight, maxLevel } =\n    useContributionGraph()\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  return (\n    <rect\n      className={cn(THEME, className)}\n      data-count={activity.count}\n      data-date={activity.date}\n      data-level={activity.level}\n      height={blockSize}\n      rx={blockRadius}\n      ry={blockRadius}\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  title = \"Contribution Graph\",\n  hideMonthLabels = false,\n  className,\n  children,\n  ...props\n}: ContributionGraphCalendarProps) => {\n  const { weeks, width, height, blockSize, blockMargin, labels } =\n    useContributionGraph()\n\n  const monthLabels = useMemo(\n    () => getMonthLabels(weeks, labels.months),\n    [weeks, labels.months]\n  )\n\n  return (\n    <div\n      className={cn(\n        \"no-scrollbar max-w-full scroll-fade-x overflow-x-auto overflow-y-hidden\",\n        className\n      )}\n      {...props}\n    >\n      <svg\n        className=\"block overflow-visible\"\n        height={height}\n        viewBox={`0 0 ${width} ${height}`}\n        width={width}\n      >\n        <title>{title}</title>\n        {!hideMonthLabels && (\n          <g\n            data-slot=\"month-labels\"\n            className=\"fill-current selection:fill-selection-foreground\"\n          >\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 } = useContributionGraph()\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, blockMargin } =\n    useContributionGraph()\n\n  return (\n    <div\n      className={cn(\"ml-auto flex items-center\", className)}\n      style={{ gap: blockMargin }}\n      {...props}\n    >\n      <span className=\"mr-1 text-muted-foreground\">\n        {labels.legend?.less || \"Less\"}\n      </span>\n\n      {new Array(maxLevel + 1).fill(undefined).map((_, level) =>\n        children ? (\n          <Fragment key={level}>{children({ level })}</Fragment>\n        ) : (\n          <svg height={blockSize} key={level} width={blockSize}>\n            <title>{`${level} contributions`}</title>\n            <rect\n              className={cn(THEME)}\n              data-level={level}\n              height={blockSize}\n              rx={blockRadius}\n              ry={blockRadius}\n              width={blockSize}\n            />\n          </svg>\n        )\n      )}\n\n      <span className=\"ml-1 text-muted-foreground\">\n        {labels.legend?.more || \"More\"}\n      </span>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "@components/contribution-graph.tsx"
    }
  ],
  "docs": "https://www.kibo-ui.com/components/contribution-graph",
  "categories": [
    "data-display"
  ],
  "type": "registry:component"
}