|
| 1 | +import React, { useMemo } from "react"; |
| 2 | + |
| 3 | +function ActivityHeatmap({ activityDates }) { |
| 4 | + // Generate last 90 days |
| 5 | + const last90Days = useMemo(() => { |
| 6 | + const dates = []; |
| 7 | + const today = new Date(); |
| 8 | + for (let i = 89; i >= 0; i--) { |
| 9 | + const d = new Date(); |
| 10 | + d.setDate(today.getDate() - i); |
| 11 | + dates.push(d); |
| 12 | + } |
| 13 | + return dates; |
| 14 | + }, []); |
| 15 | + |
| 16 | + // Map activity dates to a Set for O(1) lookup |
| 17 | + const activitySet = useMemo(() => new Set(activityDates), [activityDates]); |
| 18 | + |
| 19 | + // Helper to format date YYYY-MM-DD |
| 20 | + const formatDate = (date) => date.toISOString().split("T")[0]; |
| 21 | + |
| 22 | + // Weekday labels to show on the left (Sun, Mon, Wed, Fri) |
| 23 | + const weekdayLabels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; |
| 24 | + const visibleWeekdayLabels = ["Sun", "Mon", "Wed", "Fri"]; |
| 25 | + |
| 26 | + // Prepare weeks data (group dates by week starting on Sunday) |
| 27 | + // We want columns = weeks, rows = days (0=Sun to 6=Sat) |
| 28 | + const weeks = []; |
| 29 | + let currentWeek = []; |
| 30 | + let currentWeekStartDay = last90Days[0].getDay(); |
| 31 | + // Pad first week with nulls if first day is not Sunday |
| 32 | + for (let i = 0; i < currentWeekStartDay; i++) { |
| 33 | + currentWeek.push(null); |
| 34 | + } |
| 35 | + last90Days.forEach((date) => { |
| 36 | + if (currentWeek.length === 7) { |
| 37 | + weeks.push(currentWeek); |
| 38 | + currentWeek = []; |
| 39 | + } |
| 40 | + currentWeek.push(date); |
| 41 | + }); |
| 42 | + // Fill last week with nulls if needed |
| 43 | + while (currentWeek.length < 7) { |
| 44 | + currentWeek.push(null); |
| 45 | + } |
| 46 | + weeks.push(currentWeek); |
| 47 | + |
| 48 | + // Get month labels for top row |
| 49 | + // For each week, show the month label only once when the month changes |
| 50 | + const monthLabels = []; |
| 51 | + let lastMonth = null; |
| 52 | + weeks.forEach((week) => { |
| 53 | + // Find first non-null date in week |
| 54 | + const firstDate = week.find((d) => d !== null); |
| 55 | + if (firstDate) { |
| 56 | + const month = firstDate.toLocaleString("default", { month: "short" }); |
| 57 | + if (month !== lastMonth) { |
| 58 | + monthLabels.push(month); |
| 59 | + lastMonth = month; |
| 60 | + } else { |
| 61 | + monthLabels.push(""); |
| 62 | + } |
| 63 | + } else { |
| 64 | + monthLabels.push(""); |
| 65 | + } |
| 66 | + }); |
| 67 | + |
| 68 | + const totalContributions = activityDates.length; |
| 69 | + |
| 70 | + return ( |
| 71 | + <div className="overflow-x-auto"> |
| 72 | + <div className="flex scale-90 sm:scale-100"> |
| 73 | + {/* Left column with month label row height and weekday labels */} |
| 74 | + <div className="flex flex-col"> |
| 75 | + {/* Contributions circle at top-left corner */} |
| 76 | + <div className="w-5 h-5 mb-1 flex items-center justify-center"> |
| 77 | + <div className="w-5 h-5 text-sm rounded-full flex items-center justify-center bg-green-500 text-white font-bold"> |
| 78 | + {totalContributions} |
| 79 | + </div> |
| 80 | + </div> |
| 81 | + {/* Weekday labels column */} |
| 82 | + <div className="grid grid-rows-7 gap-1 mr-1 text-xs md:text-xs text-gray-500 dark:text-gray-400" style={{height: "168px"}}> |
| 83 | + {weekdayLabels.map((day, idx) => |
| 84 | + visibleWeekdayLabels.includes(day) ? ( |
| 85 | + <div key={day} className="h-6 md:h-6 flex items-center justify-end pr-1 text-[10px] md:text-xs"> |
| 86 | + {day} |
| 87 | + </div> |
| 88 | + ) : ( |
| 89 | + <div key={day} className="h-6 md:h-6"></div> |
| 90 | + ) |
| 91 | + )} |
| 92 | + </div> |
| 93 | + </div> |
| 94 | + <div> |
| 95 | + {/* Month labels row */} |
| 96 | + <div className="grid grid-cols-[repeat(auto-fit,minmax(24px,1fr))] gap-1 mb-1" style={{gridTemplateColumns: `repeat(${weeks.length}, 24px)`}}> |
| 97 | + {monthLabels.map((month, idx) => ( |
| 98 | + <div key={idx} className="text-[10px] md:text-xs font-medium text-gray-600 dark:text-gray-300 text-center"> |
| 99 | + {month} |
| 100 | + </div> |
| 101 | + ))} |
| 102 | + </div> |
| 103 | + {/* Heatmap grid */} |
| 104 | + <div className="grid grid-rows-7 grid-flow-col gap-1"> |
| 105 | + {weeks.map((week, weekIdx) => |
| 106 | + week.map((date, dayIdx) => { |
| 107 | + if (!date) { |
| 108 | + return <div key={`${weekIdx}-${dayIdx}`} className="w-6 h-6"></div>; |
| 109 | + } |
| 110 | + const dateStr = formatDate(date); |
| 111 | + const isActive = activitySet.has(dateStr); |
| 112 | + return ( |
| 113 | + <div |
| 114 | + key={`${weekIdx}-${dayIdx}`} |
| 115 | + title={`${dateStr} - ${isActive ? "Active" : "No Activity"}`} |
| 116 | + className={`w-6 h-6 rounded-sm transition-colors duration-300 ${ |
| 117 | + isActive |
| 118 | + ? "bg-green-500" |
| 119 | + : "bg-gray-200 dark:bg-gray-700" |
| 120 | + }`} |
| 121 | + ></div> |
| 122 | + ); |
| 123 | + }) |
| 124 | + )} |
| 125 | + </div> |
| 126 | + </div> |
| 127 | + </div> |
| 128 | + </div> |
| 129 | + ); |
| 130 | +} |
| 131 | + |
| 132 | +export default ActivityHeatmap; |
0 commit comments