|
|
import React, { useMemo, useCallback, useEffect, useState } from "react"; |
|
|
import { Tooltip } from "@mui/material"; |
|
|
import { getWeekDateRange } from "../utils/weeklyCalendar"; |
|
|
import { getHeatmapColorIntensity } from "../utils/heatmapColors"; |
|
|
|
|
|
type WeeklyActivity = { |
|
|
date: string; |
|
|
count: number; |
|
|
level: number; |
|
|
items?: string[]; |
|
|
}; |
|
|
|
|
|
type WeeklyHeatmapProps = { |
|
|
data: WeeklyActivity[]; |
|
|
color: string; |
|
|
}; |
|
|
|
|
|
const WeeklyHeatmap: React.FC<WeeklyHeatmapProps> = ({ data, color }) => { |
|
|
|
|
|
const [isDark, setIsDark] = useState(false); |
|
|
|
|
|
useEffect(() => { |
|
|
const checkTheme = () => { |
|
|
setIsDark(document.documentElement.classList.contains('dark')); |
|
|
}; |
|
|
|
|
|
|
|
|
checkTheme(); |
|
|
|
|
|
|
|
|
const observer = new MutationObserver(checkTheme); |
|
|
observer.observe(document.documentElement, { |
|
|
attributes: true, |
|
|
attributeFilter: ['class'] |
|
|
}); |
|
|
|
|
|
return () => observer.disconnect(); |
|
|
}, []); |
|
|
|
|
|
const groupedData = useMemo(() => { |
|
|
return data.reduce((acc, activity) => { |
|
|
const date = new Date(activity.date); |
|
|
const yearMonth = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; |
|
|
|
|
|
if (!acc[yearMonth]) { |
|
|
acc[yearMonth] = []; |
|
|
} |
|
|
acc[yearMonth].push(activity); |
|
|
return acc; |
|
|
}, {} as Record<string, WeeklyActivity[]>); |
|
|
}, [data]); |
|
|
|
|
|
|
|
|
const sortedMonths = useMemo(() => Object.keys(groupedData).sort(), [groupedData]); |
|
|
|
|
|
|
|
|
const getColorIntensity = useCallback((level: number) => { |
|
|
return getHeatmapColorIntensity(level, color, isDark) || undefined; |
|
|
}, [color, isDark]); |
|
|
|
|
|
|
|
|
const emptyDotColor = useMemo(() => { |
|
|
return isDark ? '#374151' : '#d1d5db'; |
|
|
}, [isDark]); |
|
|
|
|
|
|
|
|
const getMonthName = (yearMonth: string) => { |
|
|
const [year, month] = yearMonth.split('-'); |
|
|
const date = new Date(parseInt(year), parseInt(month) - 1); |
|
|
return date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }); |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<div className="w-full"> |
|
|
<div className="flex flex-wrap gap-4 justify-center"> |
|
|
{sortedMonths.map((yearMonth) => { |
|
|
const monthData = groupedData[yearMonth]; |
|
|
return ( |
|
|
<div key={yearMonth} className="flex flex-col items-center"> |
|
|
<div className="text-xs mb-2 text-gray-600 dark:text-gray-400"> |
|
|
{getMonthName(yearMonth)} |
|
|
</div> |
|
|
<div className="flex gap-1"> |
|
|
{monthData.map((activity, index) => { |
|
|
const itemsText = activity.items && activity.items.length > 0 |
|
|
? activity.items.join(', ') |
|
|
: 'No releases'; |
|
|
|
|
|
const tooltipTitle = activity.count > 0 |
|
|
? `${activity.count} new repos in week of ${getWeekDateRange(activity.date)}: ${itemsText}` |
|
|
: `No repos in week of ${getWeekDateRange(activity.date)}`; |
|
|
|
|
|
return ( |
|
|
<Tooltip |
|
|
key={`${yearMonth}-${index}`} |
|
|
title={tooltipTitle} |
|
|
arrow |
|
|
> |
|
|
<div |
|
|
className="w-3 h-3 rounded-sm cursor-pointer transition-opacity hover:opacity-80" |
|
|
style={{ |
|
|
backgroundColor: activity.level === 0 ? emptyDotColor : getColorIntensity(activity.level), |
|
|
}} |
|
|
/> |
|
|
</Tooltip> |
|
|
); |
|
|
})} |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
})} |
|
|
</div> |
|
|
|
|
|
{/* Legend */} |
|
|
<div className="flex items-center justify-center mt-4 text-xs text-gray-600 dark:text-gray-400"> |
|
|
<span className="mr-2">Less</span> |
|
|
<div className="flex gap-1"> |
|
|
{[0, 1, 2, 3, 4].map((level) => ( |
|
|
<div |
|
|
key={level} |
|
|
className="w-2.5 h-2.5 rounded-sm" |
|
|
style={{ |
|
|
backgroundColor: level === 0 ? emptyDotColor : getColorIntensity(level), |
|
|
}} |
|
|
/> |
|
|
))} |
|
|
</div> |
|
|
<span className="ml-2">More</span> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
export default React.memo(WeeklyHeatmap); |