{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "contribution-graph", "title": "Contribution Graph", "author": "ncdai ", "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": "// Thanks https://www.kibo-ui.com/components/contribution-graph\n\n\"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 subWeeks,\n} from \"date-fns\"\nimport {\n createContext,\n type CSSProperties,\n Fragment,\n type HTMLAttributes,\n type ReactNode,\n useContext,\n useMemo,\n} from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type Activity = {\n date: string\n count: number\n level: number\n}\n\ntype Week = Array\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(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(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((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 & {\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 \n \n \n )\n}\n\nexport type ContributionGraphBlockProps = HTMLAttributes & {\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 \n )\n}\n\nexport type ContributionGraphCalendarProps = Omit<\n HTMLAttributes,\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 \n \n {title}\n {!hideMonthLabels && (\n \n {monthLabels.map(({ label, weekIndex }) => (\n \n {label}\n \n ))}\n \n )}\n {weeks.map((week, weekIndex) =>\n week.map((activity, dayIndex) => {\n if (!activity) {\n return null\n }\n\n return (\n \n {children({ activity, dayIndex, weekIndex })}\n \n )\n })\n )}\n \n \n )\n}\n\nexport type ContributionGraphFooterProps = HTMLAttributes\n\nexport const ContributionGraphFooter = ({\n className,\n ...props\n}: ContributionGraphFooterProps) => (\n \n)\n\nexport type ContributionGraphTotalCountProps = Omit<\n HTMLAttributes,\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
\n {labels.totalCount\n ? labels.totalCount\n .replace(\"{{count}}\", String(totalCount))\n .replace(\"{{year}}\", String(year))\n : `${totalCount} activities in ${year}`}\n
\n )\n}\n\nexport type ContributionGraphLegendProps = Omit<\n HTMLAttributes,\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 \n \n {labels.legend?.less || \"Less\"}\n \n {new Array(maxLevel + 1).fill(undefined).map((_, level) =>\n children ? (\n {children({ level })}\n ) : (\n \n {`${level} contributions`}\n \n \n )\n )}\n \n {labels.legend?.more || \"More\"}\n \n \n )\n}\n", "type": "registry:component" } ], "docs": "https://www.kibo-ui.com/components/contribution-graph", "type": "registry:component" }