"use client"; import * as React from "react"; import { useState, useRef, useMemo, useEffect, createContext, useContext, } from "react"; import { motion, AnimatePresence } from "motion/react"; import { cn } from "@/lib/utils"; export type ContributionLevel = 0 | 1 | 2 | 3 | 4; export type Contribution = { date: string; count: number; level: ContributionLevel; }; export type Activity = Contribution; export type GitHubActivityVariant = "emerald" | "teal" | "github"; export interface GitHubActivityProps extends React.HTMLAttributes { username?: string; title?: string; subtitle?: string; totalContributions?: number; year?: number; contributions?: Contribution[]; data?: Contribution[]; variant?: GitHubActivityVariant; blockSize?: number; blockGap?: number; className?: string; } const MONTH_NAMES = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ]; const VARIANT_CONFIGS: Record< GitHubActivityVariant, { levels: Record; badge: string; dot: string; text: string; glow: string; } > = { teal: { levels: { 0: "bg-zinc-200/80 border border-zinc-300/60 dark:bg-white/4 dark:border-white/4", 1: "bg-[#015451]/35 border border-[#015451]/50", 2: "bg-[#015451]/65 border border-[#00c9a7]/40", 3: "bg-[#00a88c] border border-[#00c9a7]/70 shadow-[0_0_8px_rgba(0,201,167,0.35)]", 4: "bg-[#00c9a7] border border-[#5eead4] shadow-[0_0_12px_rgba(0,201,167,0.65)]", }, badge: "bg-[#00c9a7]/10 text-[#00c9a7] border border-[#00c9a7]/25", dot: "bg-[#00c9a7]", text: "text-[#00c9a7]", glow: "bg-[#00c9a7]/12", }, emerald: { levels: { 0: "bg-zinc-200/80 border border-zinc-300/60 dark:bg-white/4 dark:border-white/4", 1: "bg-[#0e4429] border border-[#006d32]/40", 2: "bg-[#006d32] border border-[#26a641]/50", 3: "bg-[#26a641] border border-[#39d353]/60 shadow-[0_0_8px_rgba(38,166,65,0.35)]", 4: "bg-[#39d353] border border-[#4ae365] shadow-[0_0_12px_rgba(57,211,83,0.6)]", }, badge: "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20", dot: "bg-emerald-400", text: "text-emerald-400", glow: "bg-emerald-500/12", }, github: { levels: { 0: "bg-zinc-200/80 border border-zinc-300/60 dark:bg-white/4 dark:border-white/4", 1: "bg-[#0e4429] border border-[#006d32]/40", 2: "bg-[#006d32] border border-[#26a641]/50", 3: "bg-[#26a641] border border-[#39d353]/60 shadow-[0_0_8px_rgba(38,166,65,0.35)]", 4: "bg-[#39d353] border border-[#4ae365] shadow-[0_0_12px_rgba(57,211,83,0.6)]", }, badge: "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20", dot: "bg-emerald-400", text: "text-emerald-400", glow: "bg-emerald-500/12", }, }; type Week = Array; type MonthLabel = { weekIndex: number; label: string; }; function generateYearData(targetYear: number): Contribution[] { const result: Contribution[] = []; const start = new Date(targetYear, 0, 1); const end = new Date(targetYear, 11, 31); const current = new Date(start); while (current <= end) { const year = current.getFullYear(); const month = String(current.getMonth() + 1).padStart(2, "0"); const day = String(current.getDate()).padStart(2, "0"); const dateStr = `${year}-${month}-${day}`; const seed = (targetYear * 365 + (current.getMonth() + 1) * 31 + current.getDate()) * 17; const pseudoRand = ((seed * 9301 + 49297) % 233280) / 233280; let level: ContributionLevel = 0; let count = 0; if (pseudoRand > 0.82) { level = 4; count = 13 + Math.floor(pseudoRand * 12); } else if (pseudoRand > 0.62) { level = 3; count = 7 + Math.floor(pseudoRand * 6); } else if (pseudoRand > 0.42) { level = 2; count = 3 + Math.floor(pseudoRand * 4); } else if (pseudoRand > 0.22) { level = 1; count = 1 + Math.floor(pseudoRand * 2); } result.push({ date: dateStr, count, level, }); current.setDate(current.getDate() + 1); } return result; } function parseDateString(dateStr: string): Date { const [y, m, d] = dateStr.split("-").map(Number); return new Date(y, (m || 1) - 1, d || 1); } function formatDateDisplay(dateStr: string): string { const date = parseDateString(dateStr); const m = MONTH_NAMES[date.getMonth()]; const d = date.getDate(); const y = date.getFullYear(); return `${m} ${d}, ${y}`; } function groupIntoWeeks(data: Contribution[], weekStart = 0): Week[] { if (!data || data.length === 0) return []; const sorted = [...data].sort((a, b) => a.date.localeCompare(b.date)); const firstDate = parseDateString(sorted[0].date); const firstDay = firstDate.getDay(); const leadingPadding = (firstDay - weekStart + 7) % 7; const padded: Array = [ ...new Array(leadingPadding).fill(undefined), ...sorted, ]; const weeksCount = Math.ceil(padded.length / 7); const weeks: Week[] = []; for (let w = 0; w < weeksCount; w++) { const weekSlice = padded.slice(w * 7, w * 7 + 7); while (weekSlice.length < 7) { weekSlice.push(undefined); } weeks.push(weekSlice); } return weeks; } function calculateMonthLabels(weeks: Week[]): MonthLabel[] { const labels: MonthLabel[] = []; weeks.forEach((week, weekIndex) => { const validDay = week.find((day) => day !== undefined); if (!validDay) return; const date = parseDateString(validDay.date); const month = MONTH_NAMES[date.getMonth()]; const prev = labels[labels.length - 1]; if (weekIndex === 0 || !prev || prev.label !== month) { labels.push({ weekIndex, label: month }); } }); return labels.filter(({ weekIndex }, index) => { const minWeeks = 2; if (index === 0) { return labels[1] ? labels[1].weekIndex - weekIndex >= minWeeks : true; } if (index === labels.length - 1) { return weeks.length - weekIndex >= minWeeks; } return true; }); } type ContributionGraphContextType = { data: Contribution[]; weeks: Week[]; monthLabels: MonthLabel[]; blockSize: number; blockGap: number; totalContributions: number; year: number; variant: GitHubActivityVariant; activeTheme: (typeof VARIANT_CONFIGS)["teal"]; hoveredCell: { count: number; date: string; x: number; y: number; height: number; isNearTop: boolean; } | null; setHoveredCell: React.Dispatch< React.SetStateAction<{ count: number; date: string; x: number; y: number; height: number; isNearTop: boolean; } | null> >; containerRef: React.RefObject; scrollRef: React.RefObject; }; const ContributionGraphContext = createContext(null); export function useContributionGraph() { const context = useContext(ContributionGraphContext); if (!context) { throw new Error( "ContributionGraph components must be used within a ContributionGraph provider", ); } return context; } export function ContributionGraph({ username, title = "Commit Heatmap", subtitle, totalContributions: totalContributionsProp, year = new Date().getFullYear(), contributions, data: dataProp, variant = "teal", blockSize = 10, blockGap = 3, className, children, ...props }: GitHubActivityProps & { children?: React.ReactNode }) { const containerRef = useRef(null); const scrollRef = useRef(null); const [hoveredCell, setHoveredCell] = useState<{ count: number; date: string; x: number; y: number; height: number; isNearTop: boolean; } | null>(null); const rawData = dataProp || contributions; const data = useMemo(() => { if (rawData && rawData.length > 0) { return rawData; } return generateYearData(year); }, [rawData, year]); const weeks = useMemo(() => groupIntoWeeks(data, 0), [data]); const monthLabels = useMemo(() => calculateMonthLabels(weeks), [weeks]); const calculatedTotal = useMemo( () => data.reduce((sum, item) => sum + item.count, 0), [data], ); const totalContributions = typeof totalContributionsProp === "number" ? totalContributionsProp : calculatedTotal; const activeTheme = VARIANT_CONFIGS[variant] || VARIANT_CONFIGS.teal; useEffect(() => { if (scrollRef.current && typeof window !== "undefined") { if (window.innerWidth < 768) { scrollRef.current.scrollLeft = scrollRef.current.scrollWidth; } } }, [weeks.length]); return (
{children || ( <>
{title} {subtitle || (username ? `@${username}` : "GitHub Contribution Matrix")}
)}
); } export function ContributionGraphTooltip() { const { hoveredCell, activeTheme } = useContributionGraph(); return ( {hoveredCell && (
{hoveredCell.count} commits on {formatDateDisplay(hoveredCell.date)}
)} ); } export function ContributionGraphCalendar({ className, children, ...props }: React.HTMLAttributes & { children?: (props: { activity: Contribution | undefined; dayIndex: number; weekIndex: number; }) => React.ReactNode; }) { const { weeks, monthLabels, blockSize, blockGap, scrollRef, setHoveredCell } = useContributionGraph(); const totalWidth = weeks.length * (blockSize + blockGap) - blockGap; return (
setHoveredCell(null)} className={cn( "relative z-10 overflow-x-auto scrollbar-none pb-2 pt-2.5 -mx-1 px-1 sm:mx-0 sm:px-0", className, )} {...props} >
{monthLabels.map(({ label, weekIndex }) => ( {label} ))}
{weeks.map((week, weekIndex) => (
{week.map((activity, dayIndex) => { if (children) { return ( {children({ activity, dayIndex, weekIndex })} ); } return ( ); })}
))}
); } export function ContributionGraphBlock({ activity, className, dayIndex: _dayIndex, weekIndex: _weekIndex, ...props }: { activity?: Contribution; dayIndex?: number; weekIndex?: number; } & React.HTMLAttributes) { const { activeTheme, blockSize, setHoveredCell, containerRef } = useContributionGraph(); if (!activity) { return (
); } const activateTooltip = (target: HTMLDivElement) => { const rect = target.getBoundingClientRect(); const containerRect = containerRef.current?.getBoundingClientRect(); const containerWidth = containerRect?.width || 560; const rawX = containerRect ? rect.left - containerRect.left + rect.width / 2 : 0; const clampedX = Math.max(75, Math.min(containerWidth - 75, rawX)); const y = containerRect ? rect.top - containerRect.top : 0; setHoveredCell({ count: activity.count, date: activity.date, x: clampedX, y, height: rect.height, isNearTop: y < 65, }); }; return (
activateTooltip(e.currentTarget)} onMouseLeave={() => setHoveredCell(null)} onClick={(e) => activateTooltip(e.currentTarget)} style={{ width: `${blockSize}px`, height: `${blockSize}px` }} className={cn( "rounded-[2.5px] sm:rounded-[3px] transition-all duration-150 cursor-pointer", "hover:scale-125 hover:z-20 hover:ring-1 hover:ring-white/50", activeTheme.levels[activity.level], className, )} {...props} /> ); } export function ContributionGraphFooter({ className, ...props }: React.HTMLAttributes) { return (
); } export function ContributionGraphTotalCount({ className, children, ...props }: React.HTMLAttributes & { children?: (props: { totalCount: number; year: number }) => React.ReactNode; }) { const { totalContributions, year } = useContributionGraph(); if (children) { return <>{children({ totalCount: totalContributions, year })}; } return (
{totalContributions.toLocaleString()} contributions in {year}
); } export function ContributionGraphLegend({ className, children, ...props }: React.HTMLAttributes & { children?: (props: { level: ContributionLevel }) => React.ReactNode; }) { const { activeTheme, blockSize } = useContributionGraph(); const levels: ContributionLevel[] = [0, 1, 2, 3, 4]; if (children) { return (
{levels.map((level) => ( {children({ level })} ))}
); } return (
Less {levels.map((level) => (
))} More
); } export { ContributionGraph as GitHubActivity }; export default ContributionGraph;