diff --git a/components/contributors/ActivityCalendarGitHub.tsx b/components/contributors/ActivityCalendarGitHub.tsx index 380e193a..31da16fe 100644 --- a/components/contributors/ActivityCalendarGitHub.tsx +++ b/components/contributors/ActivityCalendarGitHub.tsx @@ -1,143 +1,152 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useMemo } from "react"; import ActivityCalendar from "react-activity-calendar"; import ActivityModal from "@/components/contributors/ActivityModal"; import { useTheme } from "next-themes"; +interface ContributionDay { + date: string; + count: number; + level: number; + types?: string[]; +} + +interface ActivityModalData extends ContributionDay { + isOpen: boolean; +} + +const THEMES = { + light: ["#e5e7eb", "#d3bff3", "#b08ee6", "#976ae2", "#6025c0"], + dark: ["#374151", "#d3bff3", "#b08ee6", "#976ae2", "#6025c0"], +} as const; + export default function ActivityCalendarGit({ calendarData, }: { - calendarData: any; + calendarData: ContributionDay[]; }) { - // Force rendering the calendar only on browser as the component throws the - // following when attempted to render on server side. - // - // calcTextDimensions() requires browser APIs + // Client-side rendering check const [isBrowser, setIsBrowser] = useState(false); useEffect(() => { - setIsBrowser( - !(typeof document === "undefined" || typeof window === "undefined"), - ); + setIsBrowser(true); }, []); + // Theme const { theme } = useTheme(); - const getCalendarData = (year: number) => { - const currentYear = year; - let dates = []; - let date = new Date(`01-01-${year}`); - date.setDate(date.getDate() + 1); - while (date.getFullYear() === currentYear) { - dates.push({ - date: new Date(date).toISOString().split("T")[0], - count: 0, - level: 0, - }); - date.setDate(date.getDate() + 1); - } - dates.push({ - date: new Date(date).toISOString().split("T")[0], - count: 0, - level: 0, - }); - - let calDates = calendarData.filter( - (d: any) => d.date.slice(0, 4) === String(currentYear), + // States + const currentYear = useMemo(() => new Date().getFullYear(), []); + const [selectedYear, setSelectedYear] = useState(currentYear); + const [modalData, setModalData] = useState({ + isOpen: false, + date: "", + count: 0, + level: 0, + }); + + // Memoized data processing + const contributionsMap = useMemo(() => { + return new Map(calendarData.map((entry) => [entry.date, entry])); + }, [calendarData]); + + const availableYears = useMemo(() => { + if (!calendarData.length) return [currentYear]; + + const yearsSet = new Set( + calendarData.map((entry) => new Date(entry.date).getFullYear()), ); + yearsSet.add(currentYear); - for (let i = 0; i < dates.length; i++) - for (let j = 0; j < calDates.length; j++) - if (dates[i].date === calDates[j].date) dates[i] = calDates[j]; + return Array.from(yearsSet).sort((a, b) => b - a); + }, [calendarData, currentYear]); - return dates; - }; + const getYearDataWithContinuity = (year: number): ContributionDay[] => { + // Find the first Sunday before January 1st + const startDate = new Date(year, 0, 1); + startDate.setDate(startDate.getDate() - startDate.getDay()); - const getFirstContribYear = () => { - let i; - for (i = 0; i < calendarData.length; i++) - if (calendarData[i].count > 0) break; - return Number(calendarData[i]?.date.slice(0, 4)); - }; + // Find the last Saturday after December 31st + const endDate = new Date(year, 11, 31); + endDate.setDate(endDate.getDate() + (6 - endDate.getDay())); + + const yearDates: ContributionDay[] = []; + const currentDate = new Date(startDate); + + // Generate all dates including partial weeks + while (currentDate <= endDate) { + const dateStr = currentDate.toISOString().split("T")[0]; + yearDates.push( + contributionsMap.get(dateStr) || { + date: dateStr, + count: 0, + level: 0, + }, + ); + currentDate.setDate(currentDate.getDate() + 1); + } - const lastNYears = (n: number) => { - const currentYear = Number(new Date().getFullYear()); - let years = []; - for (let i = 0; i <= n; i++) years.push(currentYear - i); - return years; + return yearDates; }; - const yearDiff = Number(new Date().getFullYear()) - getFirstContribYear(); - const yearsList = lastNYears(yearDiff); + // Memoize the year data to prevent unnecessary recalculations + const currentYearData = useMemo( + () => getYearDataWithContinuity(selectedYear), + [selectedYear, contributionsMap], + ); - const [year, setYear] = useState(0); - const [isOpen, setIsOpen] = useState(false); - const [activityData, setActivityData] = useState({}); + if (!isBrowser) { + return null; + } return (
- {isBrowser && ( -
- {year === 0 ? ( - (data) => { - setIsOpen(true); - setActivityData(data); - }, - }} - labels={{ - totalCount: "{{count}} contributions in the last year", - }} - /> - ) : ( - (data) => { - setIsOpen(true); - setActivityData(data); - }, - }} - /> - )} - - setIsOpen(false)} - /> -
- )} +
+ (data) => { + setModalData({ + ...data, + isOpen: true, + }); + }, + }} + labels={{ + totalCount: `{{count}} contributions in ${selectedYear}`, + }} + /> + + setModalData((prev) => ({ ...prev, isOpen: false }))} + /> +
+
- {yearsList.map((y, i) => { - return ( - - ); - })} + `} + onClick={() => setSelectedYear(year)} + > + {year} + + ))}
); diff --git a/lib/api.ts b/lib/api.ts index 8c9b051e..bda9ca62 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -254,49 +254,81 @@ export async function getContributors() { } function getCalendarData(activity: Activity[]) { - const calendarData = activity.reduce( - (acc, activity) => { - const date = new Date(activity.time).toISOString().split("T")[0]; - if (!acc[date]) { - acc[date] = { - count: 0, - types: [], - }; - } - acc[date].count += 1; - if (acc[date][activity.type]) { - acc[date][activity.type] += 1; - } else { - acc[date][activity.type] = 1; - } - if (!acc[date].types.includes(activity.type)) { - acc[date].types.push(activity.type); - // console.log(activity.type); - } - return acc; - }, - {} as Record, - ); - return [...Array(365)].map((_, i) => { - // Current Date - i - const iReverse = 365 - i; - const date = new Date( - new Date().getTime() - iReverse * 24 * 60 * 60 * 1000, + if (!activity || activity.length === 0) { + return []; + } + + try { + const validDates = activity + .map((a) => new Date(a.time)) + .filter((d) => !isNaN(d.getTime())); + + if (validDates.length === 0) { + console.warn("No valid dates found in activity data"); + return []; + } + + const oldestDate = new Date( + Math.min(...validDates.map((d) => d.getTime())), + ); + const newestDate = new Date(); // Use the current date as the latest point + + // Calculate total days difference + const daysDiff = Math.ceil( + (newestDate.getTime() - oldestDate.getTime()) / (24 * 60 * 60 * 1000), ); - // yyyy-mm-dd - const dateString = `${date.getFullYear()}-${padZero( - date.getMonth() + 1, - )}-${padZero(date.getDate())}`; - const returnable = { - // date in format YYYY-MM-DD - ...calendarData[dateString], - date: dateString, - count: calendarData[dateString]?.count || 0, - level: Math.min(calendarData[dateString]?.types.length || 0, 4), - }; - // console.log("Returning", returnable); - return returnable; - }); + if (daysDiff < 0) { + console.warn(`Invalid date range detected: ${daysDiff} days`); + return []; + } + + // Build the activity map + const calendarData = activity.reduce( + (acc, activity) => { + const activityDate = new Date(activity.time); + if (isNaN(activityDate.getTime())) { + return acc; // Skip invalid dates + } + + const date = activityDate.toISOString().split("T")[0]; + if (!acc[date]) { + acc[date] = { + count: 0, + types: [], + }; + } + acc[date].count += 1; + if (acc[date][activity.type]) { + acc[date][activity.type] += 1; + } else { + acc[date][activity.type] = 1; + } + if (!acc[date].types.includes(activity.type)) { + acc[date].types.push(activity.type); + } + return acc; + }, + {} as Record, + ); + + // Generate array for all days in the range + return Array.from({ length: daysDiff + 1 }, (_, i) => { + const date = new Date(oldestDate.getTime() + i * 24 * 60 * 60 * 1000); + const dateString = `${date.getFullYear()}-${padZero( + date.getMonth() + 1, + )}-${padZero(date.getDate())}`; + + return { + ...calendarData[dateString], + date: dateString, + count: calendarData[dateString]?.count || 0, + level: Math.min(calendarData[dateString]?.types.length || 0, 4), + }; + }); + } catch (error) { + console.error("Error in getCalendarData:", error); + return []; + } } const HIGHLIGHT_KEYS = [