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