Skip to content
This repository was archived by the owner on May 27, 2026. It is now read-only.

Commit 46eb757

Browse files
committed
feat: Add ActivityHeatmap component and integrate it into LearningProgressSection for enhanced user activity visualization
1 parent 0bed439 commit 46eb757

4 files changed

Lines changed: 481 additions & 0 deletions

File tree

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
import React, { useState, useEffect, useCallback } from 'react';
2+
import { ChevronLeft, ChevronRight } from 'lucide-react';
3+
import ActivityService from '../../../utils/api/activityService';
4+
5+
/**
6+
* ActivityHeatmap Component
7+
* Beautiful GitHub-style contribution graph showing daily user activity
8+
* High UX with smooth animations, tooltips, and responsive design
9+
*/
10+
const ActivityHeatmap = ({ userId = null }) => {
11+
const [loading, setLoading] = useState(true);
12+
const [error, setError] = useState(null);
13+
const [selectedYear, setSelectedYear] = useState(new Date().getFullYear());
14+
const [availableYears, setAvailableYears] = useState([]);
15+
const [heatmapData, setHeatmapData] = useState({});
16+
const [stats, setStats] = useState(null);
17+
const [hoveredDay, setHoveredDay] = useState(null);
18+
const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 });
19+
20+
const loadActivityData = useCallback(async () => {
21+
setLoading(true);
22+
setError(null);
23+
24+
try {
25+
const response = await ActivityService.getActivityDashboard(selectedYear, userId);
26+
27+
if (response.success) {
28+
setHeatmapData(response.data.heatmap || {});
29+
setStats(response.data.stats || null);
30+
setAvailableYears(response.data.availableYears || [selectedYear]);
31+
}
32+
} catch (err) {
33+
console.error('Failed to load activity data:', err);
34+
setError('Unable to load activity data');
35+
} finally {
36+
setLoading(false);
37+
}
38+
}, [selectedYear, userId]);
39+
40+
useEffect(() => {
41+
loadActivityData();
42+
}, [loadActivityData]);
43+
44+
/**
45+
* Generate calendar grid grouped by months
46+
* Returns array of months, each containing weeks
47+
*/
48+
const generateYearGrid = () => {
49+
const months = [];
50+
51+
for (let month = 0; month < 12; month++) {
52+
const monthStart = new Date(selectedYear, month, 1);
53+
const monthEnd = new Date(selectedYear, month + 1, 0);
54+
55+
// Start from the first Sunday before or on the 1st of the month
56+
const gridStart = new Date(monthStart);
57+
gridStart.setDate(monthStart.getDate() - monthStart.getDay());
58+
59+
const weeks = [];
60+
let currentWeek = [];
61+
const currentDate = new Date(gridStart);
62+
63+
// Generate weeks for this month
64+
while (currentDate <= monthEnd || (currentWeek.length > 0 && currentWeek.length < 7)) {
65+
if (currentWeek.length === 7) {
66+
weeks.push(currentWeek);
67+
currentWeek = [];
68+
}
69+
70+
const dateStr = currentDate.toISOString().split('T')[0];
71+
const isInMonth = currentDate.getMonth() === month && currentDate.getFullYear() === selectedYear;
72+
const activity = heatmapData[dateStr];
73+
const isToday = dateStr === new Date().toISOString().split('T')[0];
74+
75+
currentWeek.push({
76+
date: new Date(currentDate),
77+
dateStr,
78+
isInMonth,
79+
activity: activity || null,
80+
isToday,
81+
});
82+
83+
currentDate.setDate(currentDate.getDate() + 1);
84+
}
85+
86+
// Add the last week if it has days
87+
if (currentWeek.length > 0) {
88+
// Fill remaining days with empty cells
89+
while (currentWeek.length < 7) {
90+
currentWeek.push({ isEmpty: true });
91+
}
92+
weeks.push(currentWeek);
93+
}
94+
95+
months.push({
96+
month,
97+
name: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][month],
98+
weeks,
99+
});
100+
}
101+
102+
return months;
103+
};
104+
105+
/**
106+
* Get color intensity based on activity count
107+
*/
108+
const getActivityColor = (activity) => {
109+
if (!activity || !activity.active) return 'bg-gray-100 hover:bg-gray-200';
110+
111+
const count = activity.count || 0;
112+
113+
if (count >= 20) return 'bg-green-600 hover:bg-green-700';
114+
if (count >= 10) return 'bg-green-500 hover:bg-green-600';
115+
if (count >= 5) return 'bg-green-400 hover:bg-green-500';
116+
if (count >= 1) return 'bg-green-300 hover:bg-green-400';
117+
118+
return 'bg-gray-100 hover:bg-gray-200';
119+
};
120+
121+
/**
122+
* Format date for tooltip
123+
*/
124+
const formatTooltipDate = (date) => {
125+
return date.toLocaleDateString('en-US', {
126+
weekday: 'long',
127+
year: 'numeric',
128+
month: 'long',
129+
day: 'numeric'
130+
});
131+
};
132+
133+
/**
134+
* Handle mouse enter on day cell
135+
*/
136+
const handleDayHover = (day, event) => {
137+
setHoveredDay(day);
138+
const rect = event.target.getBoundingClientRect();
139+
setTooltipPosition({
140+
x: rect.left + rect.width / 2,
141+
y: rect.top - 10,
142+
});
143+
};
144+
145+
const months = generateYearGrid();
146+
147+
if (loading) {
148+
return (
149+
<div className="bg-white rounded-2xl border border-gray-200 p-6 sm:p-8">
150+
<div className="animate-pulse space-y-4">
151+
<div className="h-8 bg-gray-200 rounded w-1/3"></div>
152+
<div className="h-32 bg-gray-200 rounded"></div>
153+
</div>
154+
</div>
155+
);
156+
}
157+
158+
if (error) {
159+
return (
160+
<div className="bg-white rounded-2xl border border-gray-200 p-6 sm:p-8">
161+
<div className="text-center py-8">
162+
<p className="text-gray-600">{error}</p>
163+
</div>
164+
</div>
165+
);
166+
}
167+
168+
return (
169+
<div className="bg-white rounded-2xl border border-gray-100 p-6 sm:p-8">
170+
{/* Header with Stats and Year Selector */}
171+
<div className="flex items-start justify-between mb-6 gap-6">
172+
<div className="flex-shrink-0">
173+
<h3 className="text-lg sm:text-xl font-semibold text-gray-900">Activity Calendar</h3>
174+
</div>
175+
176+
<div className="flex items-center gap-4">
177+
{/* Stats */}
178+
{stats && (
179+
<div className="flex items-center gap-4">
180+
<div className="text-center">
181+
<p className="text-xl font-bold text-gray-900">{stats.currentStreak}</p>
182+
<p className="text-[10px] text-gray-600">Current Streak</p>
183+
</div>
184+
<div className="w-px h-8 bg-gray-200"></div>
185+
<div className="text-center">
186+
<p className="text-xl font-bold text-gray-900">{stats.longestStreak}</p>
187+
<p className="text-[10px] text-gray-600">Best Streak</p>
188+
</div>
189+
<div className="w-px h-8 bg-gray-200"></div>
190+
<div className="text-center">
191+
<p className="text-xl font-bold text-gray-900">{stats.totalActiveDays}</p>
192+
<p className="text-[10px] text-gray-600">Total Days</p>
193+
</div>
194+
</div>
195+
)}
196+
197+
{/* Year Selector */}
198+
{availableYears.length > 1 && (
199+
<>
200+
<div className="w-px h-8 bg-gray-200"></div>
201+
<div className="flex items-center gap-2">
202+
<button
203+
onClick={() => {
204+
const currentIndex = availableYears.indexOf(selectedYear);
205+
if (currentIndex < availableYears.length - 1) {
206+
setSelectedYear(availableYears[currentIndex + 1]);
207+
}
208+
}}
209+
disabled={availableYears.indexOf(selectedYear) === availableYears.length - 1}
210+
className="p-2 rounded-lg hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
211+
>
212+
<ChevronLeft className="w-4 h-4" />
213+
</button>
214+
215+
<span className="text-sm font-semibold text-gray-700 min-w-[60px] text-center">
216+
{selectedYear}
217+
</span>
218+
219+
<button
220+
onClick={() => {
221+
const currentIndex = availableYears.indexOf(selectedYear);
222+
if (currentIndex > 0) {
223+
setSelectedYear(availableYears[currentIndex - 1]);
224+
}
225+
}}
226+
disabled={availableYears.indexOf(selectedYear) === 0}
227+
className="p-2 rounded-lg hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
228+
>
229+
<ChevronRight className="w-4 h-4" />
230+
</button>
231+
</div>
232+
</>
233+
)}
234+
</div>
235+
</div>
236+
237+
{/* Heatmap Grid */}
238+
<div className="overflow-x-auto pb-2">
239+
<div className="inline-block min-w-full">
240+
{/* Month Labels Row */}
241+
<div className="flex mb-3">
242+
{/* Spacer for day labels */}
243+
<div className="w-[38px] flex-shrink-0"></div>
244+
245+
{/* Month labels */}
246+
{months.map((monthData) => (
247+
<div
248+
key={monthData.month}
249+
className="text-sm font-semibold text-gray-700 mr-1"
250+
style={{ width: `${monthData.weeks.length * 13}px` }}
251+
>
252+
{monthData.name}
253+
</div>
254+
))}
255+
</div>
256+
257+
{/* Grid */}
258+
<div className="flex gap-0">
259+
{/* Day labels - Show only once */}
260+
<div className="flex flex-col gap-[2px] mr-2 flex-shrink-0">
261+
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((day) => (
262+
<div
263+
key={day}
264+
className="h-[11px] w-[32px] text-[9px] text-gray-500 font-medium flex items-center justify-start"
265+
>
266+
{day}
267+
</div>
268+
))}
269+
</div>
270+
271+
{/* All months in horizontal row */}
272+
{months.map((monthData) => (
273+
<div key={monthData.month} className="flex gap-[2px] mr-1">
274+
{monthData.weeks.map((week, weekIndex) => (
275+
<div key={weekIndex} className="flex flex-col gap-[2px]">
276+
{week.map((day, dayIndex) => (
277+
day.isEmpty ? (
278+
<div key={dayIndex} className="w-[11px] h-[11px]" />
279+
) : (
280+
<div
281+
key={dayIndex}
282+
onMouseEnter={(e) => day.isInMonth && handleDayHover(day, e)}
283+
onMouseLeave={() => setHoveredDay(null)}
284+
className={`
285+
w-[11px] h-[11px] rounded-[2px] transition-all duration-150
286+
${day.isInMonth ? getActivityColor(day.activity) : 'bg-transparent'}
287+
${day.isInMonth ? 'cursor-pointer hover:ring-1 hover:ring-gray-400' : ''}
288+
`}
289+
title={day.isInMonth ? formatTooltipDate(day.date) : ''}
290+
/>
291+
)
292+
))}
293+
</div>
294+
))}
295+
</div>
296+
))}
297+
</div>
298+
299+
{/* Legend */}
300+
<div className="flex items-center justify-end gap-2 mt-4 pt-3 border-t border-gray-100">
301+
<span className="text-[10px] text-gray-500">Less</span>
302+
<div className="flex gap-[3px]">
303+
<div className="w-[11px] h-[11px] rounded-[2px] bg-gray-100 border border-gray-200"></div>
304+
<div className="w-[11px] h-[11px] rounded-[2px] bg-green-300"></div>
305+
<div className="w-[11px] h-[11px] rounded-[2px] bg-green-400"></div>
306+
<div className="w-[11px] h-[11px] rounded-[2px] bg-green-500"></div>
307+
<div className="w-[11px] h-[11px] rounded-[2px] bg-green-600"></div>
308+
</div>
309+
<span className="text-[10px] text-gray-500">More</span>
310+
</div>
311+
</div>
312+
</div>
313+
314+
{/* Tooltip */}
315+
{hoveredDay && hoveredDay.isInMonth && (
316+
<div
317+
className="fixed z-50 pointer-events-none"
318+
style={{
319+
left: `${tooltipPosition.x}px`,
320+
top: `${tooltipPosition.y}px`,
321+
transform: 'translate(-50%, -100%)',
322+
}}
323+
>
324+
<div className="bg-gray-900 text-white text-xs rounded-lg px-3 py-2 shadow-lg whitespace-nowrap">
325+
<div className="font-semibold">{formatTooltipDate(hoveredDay.date)}</div>
326+
<div className="text-gray-300 mt-1">
327+
{hoveredDay.activity?.active
328+
? `${hoveredDay.activity.count} ${hoveredDay.activity.count === 1 ? 'activity' : 'activities'}`
329+
: 'No activity'}
330+
</div>
331+
{/* Arrow */}
332+
<div className="absolute left-1/2 -translate-x-1/2 -bottom-1 w-2 h-2 bg-gray-900 transform rotate-45"></div>
333+
</div>
334+
</div>
335+
)}
336+
</div>
337+
);
338+
};
339+
340+
export default ActivityHeatmap;

src/components/features/dashboard/LearningProgressSection.jsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
33
import { ChevronRight, Play, CheckSquare, AlertCircle, Trophy, Clock, Target, Search } from 'lucide-react';
44
import WelcomeBanner from './WelcomeBanner';
55
import AssignmentManagement from './AssignmentManagement';
6+
import ActivityHeatmap from './ActivityHeatmap';
67
import OptimizedDashboardService from '../../../utils/api/optimizedDashboardService';
78
import ProgressService from '../../../utils/api/progressService';
89
import ResourceLoadingIndicator, { StatisticLoader, ResourceProgressBadge } from '../../common/ResourceLoadingIndicator';
@@ -344,6 +345,11 @@ const LearningProgressSection = ({ user }) => {
344345
</section>
345346
)}
346347

348+
{/* Activity Heatmap - Show for all users */}
349+
<div className="relative">
350+
<ActivityHeatmap />
351+
</div>
352+
347353
{/* Active Leagues Section - Apple Style Enhanced */}
348354
{dashboardData?.enrollments?.length > 0 && (
349355
<section className="bg-white/80 backdrop-blur-sm rounded-2xl sm:rounded-3xl border border-gray-200/30 overflow-hidden">

src/components/features/dashboard/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ export { default as ProgressCard } from './ProgressCard';
88
export { default as ProgressDashboard } from './ProgressDashboard';
99
export { default as UserProfileSection } from './UserProfileSection';
1010
export { default as WelcomeBanner } from './WelcomeBanner';
11+
export { default as ActivityHeatmap } from './ActivityHeatmap';

0 commit comments

Comments
 (0)