{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"A ContributionGraph component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport type React from \"react\";\nimport { useMemo, useState } from \"react\";\n\nexport interface ContributionData {\n  count: number;\n  date: string;\n  level: number;\n}\n\nexport interface ContributionGraphProps {\n  className?: string;\n  data?: ContributionData[];\n  showLegend?: boolean;\n  showTooltips?: boolean;\n  year?: number;\n}\n\nconst WEEKS_IN_YEAR = 53;\nconst DAYS_IN_WEEK = 7;\nconst JANUARY_MONTH = 0;\nconst DECEMBER_MONTH = 11;\nconst SUNDAY_DAY = 0;\nconst MIN_WEEKS_FOR_DECEMBER_HEADER = 2;\nconst TOOLTIP_OFFSET_X = 10;\nconst TOOLTIP_OFFSET_Y = 40;\n\nconst MONTHS = [\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 DAYS = [\"Sun\", \"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\"];\n\n// Contribution level colors (similar to GitHub's)\nconst CONTRIBUTION_COLORS = [\n  \"bg-primary\", // Level 0 - No contributions\n  \"bg-brand/25\", // Level 1\n  \"bg-brand/50\", // Level 2\n  \"bg-brand/75\", // Level 3\n  \"bg-brand\", // Level 4 - Max\n];\n\nconst LEVEL_0 = 0;\nconst LEVEL_1 = 1;\nconst LEVEL_2 = 2;\nconst LEVEL_3 = 3;\nconst LEVEL_4 = 4;\nconst CONTRIBUTION_LEVELS = [LEVEL_0, LEVEL_1, LEVEL_2, LEVEL_3, LEVEL_4];\nconst DAY_1 = 1;\nconst DAY_31 = 31;\n\n// Helper function to check if date is in valid range\nconst isDateInValidRange = (\n  currentDate: Date,\n  startDate: Date,\n  endDate: Date,\n  targetYear: number\n) => {\n  const isInRange = currentDate >= startDate && currentDate <= endDate;\n  const isPreviousYearDecember =\n    currentDate.getFullYear() === targetYear - 1 &&\n    currentDate.getMonth() === DECEMBER_MONTH;\n  const isNextYearJanuary =\n    currentDate.getFullYear() === targetYear + 1 &&\n    currentDate.getMonth() === JANUARY_MONTH;\n  return isInRange || isPreviousYearDecember || isNextYearJanuary;\n};\n\n// Helper function to create day data\nconst createDayData = (\n  currentDate: Date,\n  contributionData: ContributionData[]\n): ContributionData => {\n  const [dateString] = currentDate.toISOString().split(\"T\");\n  const existingData = contributionData.find((d) => d.date === dateString);\n  return {\n    count: existingData?.count ?? LEVEL_0,\n    date: dateString,\n    level: existingData?.level ?? LEVEL_0,\n  };\n};\n\n// Helper function to check if month should be shown\ninterface MonthHeaderCheck {\n  currentMonth: number;\n  currentYear: number;\n  startDateDay: number;\n  targetYear: number;\n  weekCount: number;\n}\nconst shouldShowMonthHeader = ({\n  currentYear,\n  targetYear,\n  currentMonth,\n  startDateDay,\n  weekCount,\n}: MonthHeaderCheck) =>\n  currentYear === targetYear ||\n  (currentYear === targetYear - 1 &&\n    currentMonth === DECEMBER_MONTH &&\n    startDateDay !== SUNDAY_DAY &&\n    weekCount >= MIN_WEEKS_FOR_DECEMBER_HEADER);\n\n// Helper function to calculate month headers\nconst calculateMonthHeaders = (targetYear: number) => {\n  const headers: { month: string; colspan: number; startWeek: number }[] = [];\n  const startDate = new Date(targetYear, JANUARY_MONTH, DAY_1);\n  const firstSunday = new Date(startDate);\n  firstSunday.setDate(startDate.getDate() - startDate.getDay());\n\n  let currentMonth = -1;\n  let currentYear = -1;\n  let monthStartWeek = 0;\n  let weekCount = 0;\n\n  for (let weekNumber = 0; weekNumber < WEEKS_IN_YEAR; weekNumber++) {\n    const weekDate = new Date(firstSunday);\n    weekDate.setDate(firstSunday.getDate() + weekNumber * DAYS_IN_WEEK);\n\n    const monthKey = weekDate.getMonth();\n    const yearKey = weekDate.getFullYear();\n\n    if (monthKey !== currentMonth || yearKey !== currentYear) {\n      if (\n        currentMonth !== -1 &&\n        shouldShowMonthHeader({\n          currentMonth,\n          currentYear,\n          startDateDay: startDate.getDay(),\n          targetYear,\n          weekCount,\n        })\n      ) {\n        headers.push({\n          colspan: weekCount,\n          month: MONTHS[currentMonth],\n          startWeek: monthStartWeek,\n        });\n      }\n      currentMonth = monthKey;\n      currentYear = yearKey;\n      monthStartWeek = weekNumber;\n      weekCount = 1;\n    } else {\n      weekCount++;\n    }\n  }\n\n  // Add the last month\n  if (\n    currentMonth !== -1 &&\n    shouldShowMonthHeader({\n      currentMonth,\n      currentYear,\n      startDateDay: startDate.getDay(),\n      targetYear,\n      weekCount,\n    })\n  ) {\n    headers.push({\n      colspan: weekCount,\n      month: MONTHS[currentMonth],\n      startWeek: monthStartWeek,\n    });\n  }\n\n  return headers;\n};\n\nexport function ContributionGraph({\n  data = [],\n  year = new Date().getFullYear(),\n  className = \"\",\n  showLegend = true,\n  showTooltips = true,\n}: ContributionGraphProps) {\n  const [hoveredDay, setHoveredDay] = useState<ContributionData | null>(null);\n  const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 });\n  const shouldReduceMotion = useReducedMotion();\n\n  // Generate all days for the year\n  const yearData = useMemo(() => {\n    const startDate = new Date(year, JANUARY_MONTH, DAY_1);\n    const endDate = new Date(year, DECEMBER_MONTH, DAY_31);\n    const days: ContributionData[] = [];\n\n    // Start from the Sunday of the first week that contains January 1st\n    // This ensures December gets proper weeks before January\n    const firstSunday = new Date(startDate);\n    firstSunday.setDate(startDate.getDate() - startDate.getDay());\n\n    // Generate 53 weeks (GitHub shows 53 weeks)\n    for (let weekNum = 0; weekNum < WEEKS_IN_YEAR; weekNum++) {\n      for (let day = 0; day < DAYS_IN_WEEK; day++) {\n        const currentDate = new Date(firstSunday);\n        currentDate.setDate(\n          firstSunday.getDate() + weekNum * DAYS_IN_WEEK + day\n        );\n\n        if (isDateInValidRange(currentDate, startDate, endDate, year)) {\n          days.push(createDayData(currentDate, data));\n        } else {\n          // Add empty day for alignment\n          days.push({\n            count: LEVEL_0,\n            date: \"\",\n            level: LEVEL_0,\n          });\n        }\n      }\n    }\n\n    return days;\n  }, [data, year]);\n\n  // Calculate month headers with colspan\n  const monthHeaders = useMemo(() => calculateMonthHeaders(year), [year]);\n\n  const handleDayHover = (day: ContributionData, event: React.MouseEvent) => {\n    if (showTooltips && day.date) {\n      setHoveredDay(day);\n      setTooltipPosition({ x: event.clientX, y: event.clientY });\n    }\n  };\n\n  const handleDayLeave = () => {\n    setHoveredDay(null);\n  };\n\n  const formatDate = (dateString: string) => {\n    if (!dateString) {\n      return \"\";\n    }\n    const date = new Date(dateString);\n    return date.toLocaleDateString(\"en-US\", {\n      day: \"numeric\",\n      month: \"long\",\n      weekday: \"long\",\n      year: \"numeric\",\n    });\n  };\n\n  const getContributionText = (count: number) => {\n    if (count === LEVEL_0) {\n      return \"No contributions\";\n    }\n    if (count === LEVEL_1) {\n      return \"1 contribution\";\n    }\n    return `${count} contributions`;\n  };\n\n  return (\n    <div className={`contribution-graph ${className}`}>\n      <div className=\"overflow-x-auto\">\n        <table className=\"border-separate border-spacing-1 text-xs\">\n          <caption className=\"sr-only\">Contribution Graph for {year}</caption>\n\n          {/* Month Headers */}\n          <thead>\n            <tr className=\"h-3\">\n              <td className=\"w-7 min-w-7\" />\n              {monthHeaders.map((header) => (\n                <td\n                  className=\"relative text-left text-foreground\"\n                  colSpan={header.colspan}\n                  key={`${header.month}-${header.startWeek}`}\n                >\n                  <span className=\"absolute top-0 left-1\">{header.month}</span>\n                </td>\n              ))}\n            </tr>\n          </thead>\n\n          {/* Day Grid */}\n          <tbody>\n            {Array.from({ length: DAYS_IN_WEEK }, (_, dayIndex) => (\n              <tr className=\"h-2.5\" key={DAYS[dayIndex]}>\n                {/* Day Labels */}\n                <td className=\"relative w-7 min-w-7 text-foreground\">\n                  {dayIndex % 2 === 0 && (\n                    <span className=\"absolute -bottom-0.5 left-0 text-xs\">\n                      {DAYS[dayIndex]}\n                    </span>\n                  )}\n                </td>\n\n                {/* Day Cells */}\n                {Array.from({ length: WEEKS_IN_YEAR }, (_week, weekIndex) => {\n                  const dayData = yearData[weekIndex * DAYS_IN_WEEK + dayIndex];\n                  const cellKey = `${dayData?.date ?? \"empty\"}-${weekIndex}-${dayIndex}`;\n                  if (!dayData?.date) {\n                    return (\n                      <td className=\"h-2.5 w-2.5 p-0\" key={cellKey}>\n                        <div className=\"h-2.5 w-2.5\" />\n                      </td>\n                    );\n                  }\n\n                  return (\n                    // biome-ignore lint/a11y/noNoninteractiveElementInteractions: Table cell is interactive for hover tooltips\n                    <td\n                      className=\"h-2.5 w-2.5 cursor-pointer p-0\"\n                      key={cellKey}\n                      onMouseEnter={(e) => handleDayHover(dayData, e)}\n                      onMouseLeave={handleDayLeave}\n                      title={\n                        showTooltips\n                          ? `${formatDate(dayData.date)}: ${getContributionText(dayData.count)}`\n                          : undefined\n                      }\n                    >\n                      <div\n                        className={`h-2.5 w-2.5 rounded-sm ${\n                          CONTRIBUTION_COLORS[dayData.level]\n                        } hover:ring-2 hover:ring-background`}\n                      />\n                    </td>\n                  );\n                })}\n              </tr>\n            ))}\n          </tbody>\n        </table>\n      </div>\n\n      {/* Tooltip */}\n      {showTooltips && hoveredDay ? (\n        <motion.div\n          animate={\n            shouldReduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1 }\n          }\n          className=\"pointer-events-none fixed z-50 rounded-lg border bg-primary px-3 py-2 text-foreground text-sm shadow-lg\"\n          exit={\n            shouldReduceMotion\n              ? { opacity: 0, transition: { duration: 0 } }\n              : { opacity: 0, scale: 0.8 }\n          }\n          initial={\n            shouldReduceMotion ? { opacity: 1 } : { opacity: 0, scale: 0.8 }\n          }\n          style={{\n            left: tooltipPosition.x + TOOLTIP_OFFSET_X,\n            top: tooltipPosition.y - TOOLTIP_OFFSET_Y,\n          }}\n          transition={shouldReduceMotion ? { duration: 0 } : { duration: 0.2 }}\n        >\n          <div className=\"font-semibold\">\n            {getContributionText(hoveredDay.count)}\n          </div>\n          <div className=\"text-foreground/70\">\n            {formatDate(hoveredDay.date)}\n          </div>\n        </motion.div>\n      ) : null}\n\n      {/* Legend */}\n      {showLegend ? (\n        <div className=\"mt-4 flex items-center justify-between text-foreground/70 text-xs\">\n          <span>Less</span>\n          <div className=\"flex items-center gap-1\">\n            {CONTRIBUTION_LEVELS.map((level) => (\n              <div\n                className={`h-3 w-3 rounded-sm ${CONTRIBUTION_COLORS[level]}`}\n                key={level}\n              />\n            ))}\n          </div>\n          <span>More</span>\n        </div>\n      ) : null}\n    </div>\n  );\n}\n\nexport default ContributionGraph;\n","path":"index.tsx","target":"components/smoothui/contribution-graph/index.tsx","type":"registry:ui"}],"name":"contribution-graph","registryDependencies":["https://smoothui.dev/r/tokens.json"],"title":"Contribution Graph","type":"registry:ui"}