plebdevs/src/components/charts/CombinedContributionChart.js
2025-04-28 10:27:46 -05:00

356 lines
13 KiB
JavaScript

import React, { useState, useCallback, useEffect } from 'react';
import { useFetchGithubCommits } from '@/hooks/githubQueries/useFetchGithubCommits';
import useWindowWidth from '@/hooks/useWindowWidth';
import MoreInfo from '@/components/MoreInfo';
const CombinedContributionChart = ({ session }) => {
const [contributionData, setContributionData] = useState({});
const [totalContributions, setTotalContributions] = useState(0);
const windowWidth = useWindowWidth();
const [retryCount, setRetryCount] = useState(0);
const prepareProgressData = useCallback(() => {
if (!session?.user?.userCourses) return {};
const activityData = {};
const allActivities = []; // Array to store all activities for logging
// Process course activities
session.user.userCourses.forEach(courseProgress => {
if (courseProgress.started) {
const startDate = new Date(courseProgress.startedAt);
const date = new Date(
startDate.getTime() - startDate.getTimezoneOffset() * 60000
).toLocaleDateString('en-CA');
activityData[date] = (activityData[date] || 0) + 1;
allActivities.push({
type: 'course_started',
name: courseProgress.course?.name,
date: date,
});
}
if (courseProgress.completed) {
const completeDate = new Date(courseProgress.completedAt);
const date = new Date(
completeDate.getTime() - completeDate.getTimezoneOffset() * 60000
).toLocaleDateString('en-CA');
activityData[date] = (activityData[date] || 0) + 1;
allActivities.push({
type: 'course_completed',
name: courseProgress.course?.name,
date: date,
});
}
});
// Process lesson activities
session.user.userLessons?.forEach(lessonProgress => {
if (lessonProgress.opened) {
const openDate = new Date(lessonProgress.openedAt);
const date = new Date(
openDate.getTime() - openDate.getTimezoneOffset() * 60000
).toLocaleDateString('en-CA');
activityData[date] = (activityData[date] || 0) + 1;
allActivities.push({
type: 'lesson_started',
name: lessonProgress.lesson?.name,
date: date,
});
}
if (lessonProgress.completed) {
const completeDate = new Date(lessonProgress.completedAt);
const date = new Date(
completeDate.getTime() - completeDate.getTimezoneOffset() * 60000
).toLocaleDateString('en-CA');
activityData[date] = (activityData[date] || 0) + 1;
allActivities.push({
type: 'lesson_completed',
name: lessonProgress.lesson?.name,
date: date,
});
}
});
return activityData;
}, [session]);
const handleNewCommit = useCallback(
({ contributionData, totalCommits }) => {
const activityData = prepareProgressData();
// Create a new object with GitHub commits
const combinedData = { ...contributionData };
// Add activities to the combined data
Object.entries(activityData).forEach(([date, count]) => {
combinedData[date] = (combinedData[date] || 0) + count;
});
setContributionData(combinedData);
setTotalContributions(totalCommits + Object.values(activityData).reduce((a, b) => a + b, 0));
},
[prepareProgressData]
);
const { data, isLoading, isFetching, error, refetch } = useFetchGithubCommits(
session,
handleNewCommit
);
// Add recovery logic
useEffect(() => {
if (error && retryCount < 3) {
const timer = setTimeout(
() => {
setRetryCount(prev => prev + 1);
refetch();
},
1000 * (retryCount + 1)
); // Exponential backoff
return () => clearTimeout(timer);
}
}, [error, retryCount, refetch]);
// Reset retry count on successful data fetch
useEffect(() => {
if (data) {
setRetryCount(0);
}
}, [data]);
// Add loading state check
useEffect(() => {
if (isLoading || isFetching) {
const loadingTimeout = setTimeout(() => {
if (!data) {
refetch();
}
}, 5000); // Timeout after 5 seconds
return () => clearTimeout(loadingTimeout);
}
}, [isLoading, isFetching, data, refetch]);
// Initialize from cached data if available
useEffect(() => {
if (data && !isLoading) {
const activityData = prepareProgressData();
const combinedData = { ...data.contributionData };
// Add activities to the combined data
Object.entries(activityData).forEach(([date, count]) => {
combinedData[date] = (combinedData[date] || 0) + count;
});
setContributionData(combinedData);
setTotalContributions(
data.totalCommits + Object.values(activityData).reduce((a, b) => a + b, 0)
);
}
}, [data, isLoading, prepareProgressData]);
const getColor = useCallback(count => {
if (count === 0) return 'bg-gray-100';
if (count < 3) return 'bg-green-300';
if (count < 6) return 'bg-green-400';
if (count < 12) return 'bg-green-600';
return 'bg-green-700';
}, []);
const generateCalendar = useCallback(() => {
const today = new Date();
today.setHours(23, 59, 59, 999);
// Calculate the start date (52 weeks + remaining days to today)
const oneYearAgo = new Date(today);
oneYearAgo.setDate(today.getDate() - 364); // 52 weeks * 7 days = 364 days
// Start from the first Sunday before or on oneYearAgo
const startDate = new Date(oneYearAgo);
startDate.setDate(startDate.getDate() - startDate.getDay());
const calendar = [];
// Create 7 rows for days of the week (Sunday to Saturday)
for (let i = 0; i < 7; i++) {
calendar[i] = [];
}
// Fill in the dates by week columns
let currentDate = new Date(startDate);
while (currentDate <= today) {
const weekDay = currentDate.getDay();
const dateString = currentDate.toLocaleDateString('en-CA');
const githubCount = data?.contributionData[dateString] || 0;
const activityCount =
(contributionData[dateString] || 0) - (data?.contributionData[dateString] || 0);
const totalCount = githubCount + activityCount;
calendar[weekDay].push({
date: new Date(currentDate),
count: totalCount,
githubCount,
activityCount,
});
currentDate.setDate(currentDate.getDate() + 1);
}
return calendar;
}, [contributionData, data?.contributionData]);
const calendar = generateCalendar();
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const getMonthLabels = useCallback(() => {
const today = new Date();
const oneYearAgo = new Date(today);
oneYearAgo.setFullYear(today.getFullYear() - 1);
oneYearAgo.setDate(today.getDate() + 1);
const months = [];
let currentMonth = -1;
for (let d = new Date(oneYearAgo); d <= today; d.setDate(d.getDate() + 1)) {
const month = d.getMonth();
if (month !== currentMonth) {
months.push({
name: d.toLocaleString('default', { month: 'short' }),
index: calendar[0].findIndex(
(_, weekIndex) => calendar[0][weekIndex]?.date.getMonth() === month
),
});
currentMonth = month;
}
}
return months;
}, [calendar]);
const getScaleClass = width => {
if (width <= 800) return 'overflow-x-auto';
if (width <= 1000) return 'scale-95 origin-top-left';
return '';
};
return (
<div className="w-full mx-2 bg-gray-800 rounded-lg border border-gray-700 shadow-md h-[330px] max-lap:mx-0">
<div className="flex flex-row justify-between items-center p-4">
<h1 className="text-2xl font-bold text-gray-200">Activity</h1>
<MoreInfo
tooltip="Combined total of GitHub commits and learning activities"
modalTitle="Activity Tracking"
modalBody={
<div className="space-y-4">
<p>This chart combines two types of activities:</p>
<ul className="list-disc pl-4">
<li>GitHub Commits: Your code contributions on GitHub</li>
<li>
Learning Activities:
<ul className="list-disc pl-4 mt-2">
<li>Starting and completing courses</li>
<li>Opening and completing lessons</li>
</ul>
</li>
</ul>
<p>The darker the green, the more combined activities on that day.</p>
</div>
}
className="text-2xl text-gray-200"
/>
</div>
<div className={`${getScaleClass(windowWidth)}`}>
<div className="min-w-[910px] p-4">
{(isLoading || isFetching) && (
<h4 className="text-base font-semibold text-gray-200 mb-3 flex items-center gap-2">
Loading contribution data... ({totalContributions} total contributions / activities
fetched)
<i className="pi pi-spinner animate-spin" />
</h4>
)}
{!isLoading && !isFetching && (
<div className="flex justify-between items-center mb-3">
<h4 className="text-base font-semibold text-gray-200">
{totalContributions} total contributions / activities in the last year
</h4>
</div>
)}
<div className="flex">
{/* Days of week labels */}
<div className={`flex flex-col gap-[3px] text-[11px] text-gray-400 pr-3`}>
{weekDays.map((day, index) => (
<div key={day} className="h-[13px] leading-[13px]">
{index % 2 === 0 && day}
</div>
))}
</div>
<div className="flex flex-col">
{/* Calendar grid */}
<div className="flex gap-[3px]">
{calendar[0].map((_, weekIndex) => (
<div key={weekIndex} className="flex flex-col gap-[3px]">
{calendar.map(
(row, dayIndex) =>
row[weekIndex] && (
<div
key={`${weekIndex}-${dayIndex}`}
className={`w-[14px] h-[14px] ${getColor(row[weekIndex].count)} rounded-[2px] cursor-pointer transition-colors duration-100`}
title={`${row[weekIndex].date.toDateString()}: ${
[
row[weekIndex].githubCount > 0
? `${row[weekIndex].githubCount} contribution${row[weekIndex].githubCount !== 1 ? 's' : ''}`
: '',
row[weekIndex].activityCount > 0
? `${row[weekIndex].activityCount} activit${row[weekIndex].activityCount !== 1 ? 'ies' : 'y'}`
: '',
]
.filter(Boolean)
.join(' & ') || 'No contributions or activities'
}`}
></div>
)
)}
</div>
))}
</div>
{/* Month labels moved to bottom */}
<div className="flex text-[11px] text-gray-400 h-[20px] mt-1 relative">
{getMonthLabels().map((month, index) => (
<div key={index} className="absolute" style={{ left: `${month.index * 15}px` }}>
{month.name}
</div>
))}
</div>
</div>
</div>
<div className="text-[11px] text-gray-400 flex items-center justify-start pt-4">
<span className="mr-2">Less</span>
<div className="flex gap-[3px]">
<div className="w-[14px] h-[14px] bg-gray-100 rounded-[2px]"></div>
<div className="w-[14px] h-[14px] bg-green-300 rounded-[2px]"></div>
<div className="w-[14px] h-[14px] bg-green-400 rounded-[2px]"></div>
<div className="w-[14px] h-[14px] bg-green-600 rounded-[2px]"></div>
<div className="w-[14px] h-[14px] bg-green-700 rounded-[2px]"></div>
</div>
<span className="ml-2">More</span>
</div>
{error && retryCount >= 3 && (
<div className="text-red-400 text-sm px-4">
Error loading data.{' '}
<button
onClick={() => {
setRetryCount(0);
refetch();
}}
className="text-blue-400 hover:text-blue-300"
>
Try again
</button>
</div>
)}
</div>
</div>
</div>
);
};
export default CombinedContributionChart;