diff --git a/client/package-lock.json b/client/package-lock.json index c17bf0d..facce1f 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,6 +8,7 @@ "name": "smartlist", "version": "0.1.0", "dependencies": { + "@heroicons/react": "^2.2.0", "@radix-ui/react-tooltip": "^1.1.6", "@react-oauth/google": "^0.12.1", "@testing-library/user-event": "^13.5.0", @@ -2676,6 +2677,15 @@ "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==", "license": "MIT" }, + "node_modules/@heroicons/react": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", + "integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==", + "license": "MIT", + "peerDependencies": { + "react": ">= 16 || ^19.0.0-rc" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", diff --git a/client/package.json b/client/package.json index 3c07c47..88bd886 100644 --- a/client/package.json +++ b/client/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { + "@heroicons/react": "^2.2.0", "@radix-ui/react-tooltip": "^1.1.6", "@react-oauth/google": "^0.12.1", "@testing-library/user-event": "^13.5.0", diff --git a/client/src/App.js b/client/src/App.js index 216ce46..0b2b723 100644 --- a/client/src/App.js +++ b/client/src/App.js @@ -267,16 +267,8 @@ const App = () => { {showFullLeaderboard && (
-
- -
- + setShowFullLeaderboard(false)} />
diff --git a/client/src/components/Analytics/Dashboard.js b/client/src/components/Analytics/Dashboard.js new file mode 100644 index 0000000..05da517 --- /dev/null +++ b/client/src/components/Analytics/Dashboard.js @@ -0,0 +1,400 @@ +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import { Line, Bar } from 'react-chartjs-2'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + Title, + Tooltip, + Legend +} from 'chart.js'; +import { ArrowTrendingUpIcon, ArrowTrendingDownIcon, ChartBarIcon, FireIcon } from '@heroicons/react/24/outline'; +import DashboardManager from '../../services/analytics/DashboardManager'; + +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + Title, + Tooltip, + Legend +); + +const Dashboard = ({ completedTasks, onOpenDashboard }) => { + const [isFullView, setIsFullView] = useState(false); + const [xpData, setXpData] = useState(null); + const [windowWidth, setWindowWidth] = useState(window.innerWidth); + + const dashboardManager = useMemo(() => new DashboardManager(), []); + + const periodXP = useMemo(() => + dashboardManager.calculatePeriodXP(completedTasks), + [completedTasks, dashboardManager] + ); + + const { metrics, completedTasksData } = useMemo(() => { + if (!xpData) return { metrics: null, completedTasksData: null }; + + return { + metrics: dashboardManager.getMetrics(xpData, periodXP), + completedTasksData: dashboardManager.getCompletedTasksData(completedTasks, xpData) + }; + }, [completedTasks, xpData, periodXP, dashboardManager]); + + // Memoize chart options + const chartOptions = useMemo(() => ({ + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: false + }, + tooltip: { + backgroundColor: '#1F2937', + titleColor: '#F3F4F6', + bodyColor: '#F3F4F6', + displayColors: false, + callbacks: { + label: (context) => `${context.parsed.y} XP` + } + } + }, + scales: { + y: { + beginAtZero: true, + ticks: { + color: '#6B7280' + }, + grid: { + display: false + } + }, + x: { + ticks: { + color: '#6B7280' + }, + grid: { + display: false + } + } + } + }), []); + + const tasksChartOptions = useMemo(() => ({ + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: false + }, + tooltip: { + backgroundColor: '#1F2937', + titleColor: '#F3F4F6', + bodyColor: '#F3F4F6', + displayColors: false, + callbacks: { + label: (context) => `${context.parsed.y} tasks completed` + } + } + }, + scales: { + y: { + beginAtZero: true, + ticks: { + stepSize: 1, + color: '#6B7280' + }, + grid: { + display: false + } + }, + x: { + ticks: { + color: '#6B7280' + }, + grid: { + display: false + } + } + } + }), []); + + // Add a utility function to format dates + const formatDate = useCallback((date, useWeekday = false) => { + if (useWeekday) { + return date.toLocaleDateString('en-US', { weekday: 'short' }); + } + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const day = date.getDate().toString().padStart(2, '0'); + return `${month}/${day}`; + }, []); + + // Create separate chart data for card and modal views + const getChartData = useCallback((data, useWeekday = false) => { + if (!data?.labels || !data?.datasets) return null; + + return { + ...data, + labels: data.labels.map(label => { + const [month, day] = label.split('/'); + const date = new Date(new Date().getFullYear(), Number(month) - 1, Number(day)); + return formatDate(date, useWeekday); + }) + }; + }, [formatDate]); + + const handleCloseFullView = useCallback(() => { + setIsFullView(false); + }, []); + + const renderPeakDay = useCallback(() => { + const peak = dashboardManager.findPeakDay(xpData); + if (!peak || peak.xp === 0) return "No activity yet"; + return `${peak.date} • ${peak.xp}XP`; + }, [xpData, dashboardManager]); + + // Clear cache when component unmounts + useEffect(() => { + return () => { + dashboardManager.clearCache(); + }; + }, [dashboardManager]); + + // Calculate XP data + useEffect(() => { + const data = dashboardManager.calculateXPData(completedTasks); + setXpData(data); + }, [completedTasks, dashboardManager]); + + // Setup dashboard opener + useEffect(() => { + if (onOpenDashboard) { + onOpenDashboard(() => setIsFullView(true)); + } + }, [onOpenDashboard]); + + // Window resize optimization + useEffect(() => { + let timeoutId; + const handleResize = () => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + setWindowWidth(window.innerWidth); + }, 150); + }; + + window.addEventListener('resize', handleResize); + return () => { + clearTimeout(timeoutId); + window.removeEventListener('resize', handleResize); + }; + }, []); + + // Move chart options to constants + const CHART_CONSTANTS = useMemo(() => ({ + fontSizes: { + small: windowWidth < 640 ? 10 : 12, + regular: windowWidth < 640 ? 12 : 14 + }, + rotations: { + x: { max: 45, min: 45 } + } + }), [windowWidth]); + + // Optimize chart data transformations + const transformedChartData = useMemo(() => ({ + xpData: xpData ? getChartData(xpData, true) : null, + modalXpData: xpData ? getChartData(xpData, false) : null, + taskData: completedTasksData ? getChartData(completedTasksData, false) : null + }), [xpData, completedTasksData, getChartData]); + + return ( +
+
+ {transformedChartData.xpData ? ( + + ) : ( +
+ No XP data available +
+ )} +
+ + {/* Modal for full view */} + {isFullView && ( +
e.target === e.currentTarget && handleCloseFullView()} + > +
+
+
+
+

+ Analytics Overview +

+

Past 7 Days

+
+ +
+ + {/* Key Metrics */} +
+
+
+ + 7-Day XP Total +
+

+ {metrics?.weeklyXP || 0} +

+

Total XP earned this week

+
+ + {/* Activity Days card */} +
+
+ + Activity Days +
+

+ {metrics?.activeDays} +

+

Days with completed tasks

+
+ + {/* Weekly Trend card */} +
+
+ {metrics?.trendDirection === 'Improving' ? ( + + ) : ( + + )} + Weekly Trend +
+

+ {metrics?.trendDescription} +

+

+ {metrics?.trendPercentage}% {metrics?.trendDirection.toLowerCase()} from last week +

+
+
+
+ + {/* Charts Section */} +
+
+
+

XP Growth

+
+ +
+
+ +
+

Task/Project Completion

+
+ +
+
+
+
+
+
+ )} + {xpData && ( +
+ {/* Peak Day card */} +
+
+ Peak Day + + {renderPeakDay()} + +
+
+ + {/* Average Daily card */} +
+
+ Average Daily + + {dashboardManager.calculateAverageDaily(completedTasks, xpData)} XP per day + +
+
+
+ )} +
+ ); +}; + +export default React.memo(Dashboard); diff --git a/client/src/components/Leaderboard/Leaderboard.js b/client/src/components/Leaderboard/Leaderboard.js index 7e89cc7..0137a7c 100644 --- a/client/src/components/Leaderboard/Leaderboard.js +++ b/client/src/components/Leaderboard/Leaderboard.js @@ -1,180 +1,150 @@ -import React, { useState, useEffect, useMemo } from 'react'; - -const API_BASE_URL = process.env.REACT_APP_PROD || 'http://localhost:3001/api'; - -const adjectives = [ - 'Happy', 'Clever', 'Brave', 'Wise', 'Swift', 'Calm', 'Bright', 'Noble', - 'Lucky', 'Witty', 'Bold', 'Quick', 'Kind', 'Cool', 'Keen', 'Pure' -]; - -const nouns = [ - 'Panda', 'Eagle', 'Tiger', 'Wolf', 'Bear', 'Fox', 'Hawk', 'Lion', - 'Deer', 'Seal', 'Owl', 'Duck', 'Cat', 'Dog', 'Bat', 'Elk' -]; - -const hashCode = (str) => { - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = ((hash << 5) - hash) + char; - hash = hash & hash; - } - return Math.abs(hash); -}; +import React, { useState, useEffect, memo } from 'react'; +import { ChartBarIcon } from '@heroicons/react/24/outline'; +import LeaderboardManager from '../../services/leaderboard/LeaderboardManager'; -const generateUsername = (userId) => { - const hash = hashCode(userId); - const adjIndex = hash % adjectives.length; - const nounIndex = Math.floor(hash / adjectives.length) % nouns.length; - const number = hash % 1000; - - return `${adjectives[adjIndex]}${nouns[nounIndex]}${number}`; -}; +const LoadingSpinner = memo(() => ( +
+ + + + +
+)); -const LeaderboardEntry = ({ user }) => { - const [showDetails, setShowDetails] = useState(false); - const generatedUsername = useMemo(() => generateUsername(user?._id), [user?._id]); +const MetricBox = memo(({ label, value }) => ( +
+
{label}
+
+ + {value} + +
+
+)); - return ( -
  • -
    -
    - +const UserListItem = memo(({ user, index, showDetails, setShowDetails }) => ( +
  • +
    +
    + {/* Position indicator */} +
    + + #{index + 1} + +
    + + {/* User info */} +
    - {user?.picture && ( - Profile + ) : ( +
    + {user?.name?.[0] || 'U'} +
    )} - - {user?.name || generatedUsername} +
    + + {user?.name || 'Anonymous User'} + + +
    +
    + + {/* XP display */} +
    + + {user?.xp || 0} XP
    -
    - {user?.xp || 0} XP -
    -
    -
    -

    Tasks Completed: {user?.tasksCompleted || 0}

    -

    Level: {user?.level || 1}

    + + {/* User Details Dropdown */} +
    +
    +
    +
    + + Level {user?.level || 1} + +
    +
    +
    + + {user?.tasksCompleted || 0} Tasks + +
    -
  • - ); -}; + + +)); -const Leaderboard = ({ limit, className, scrollUsers = false, onShowFull }) => { - const [leaderboard, setLeaderboard] = useState([]); - const [error, setError] = useState(null); - const [isOptedIn, setIsOptedIn] = useState(false); +const Leaderboard = ({ limit, className, scrollUsers = false, onShowFull, onClose }) => { + const [leaderboardState, setLeaderboardState] = useState({ + data: [], + isLoading: true, + error: null, + isOptedIn: false, + communityXP: 0 + }); + const [showDetails, setShowDetails] = useState(null); const [showTooltip, setShowTooltip] = useState(false); - const [communityXP, setCommunityXP] = useState(0); - const fetchCommunityXP = async () => { - try { - const response = await fetch(`${API_BASE_URL}/analytics`, { - credentials: 'include' - }); - - if (response.ok) { - const data = await response.json(); - setCommunityXP(data.communityXP); - } - } catch (error) { - console.error('Error fetching community XP:', error); - } - }; + const leaderboardManager = new LeaderboardManager( + (data) => setLeaderboardState(prev => ({ ...prev, data })), + (error) => setLeaderboardState(prev => ({ ...prev, error })), + (isOptedIn) => setLeaderboardState(prev => ({ ...prev, isOptedIn })), + (communityXP) => setLeaderboardState(prev => ({ ...prev, communityXP })), + (isLoading) => setLeaderboardState(prev => ({ ...prev, isLoading })) + ); - const checkOptInStatus = async () => { - try { - const response = await fetch(`${API_BASE_URL}/auth/current_user`, { - credentials: 'include' - }); - - if (response.ok) { - const userData = await response.json(); - setIsOptedIn(userData.isOptIn || false); - } else if (response.status === 401) { - setError('Please sign in to view the leaderboard'); - } - } catch (error) { - console.error('Error checking opt-in status:', error); - } - }; + useEffect(() => { + let mounted = true; - const handleOptInToggle = async () => { - try { - const userResponse = await fetch(`${API_BASE_URL}/auth/current_user`, { - credentials: 'include' - }); - - if (!userResponse.ok) { - throw new Error('Not authenticated'); - } - - const userData = await userResponse.json(); + const fetchAllData = async () => { + if (!mounted) return; - const response = await fetch(`${API_BASE_URL}/users/${userData.userId}/opt-in`, { - method: 'PUT', - credentials: 'include' - }); - - if (response.ok) { - const data = await response.json(); - setIsOptedIn(data.isOptIn); - fetchLeaderboard(); - } else if (response.status === 401) { - setError('Please sign in to change opt-in status'); + try { + await Promise.all([ + leaderboardManager.checkOptInStatus(), + leaderboardManager.fetchLeaderboard(), + leaderboardManager.fetchCommunityXP() + ]); + } catch (error) { + console.error('Error fetching leaderboard data:', error); } - } catch (error) { - console.error('Error toggling opt-in status:', error); - } - }; + }; - const fetchLeaderboard = async () => { - try { - const response = await fetch(`${API_BASE_URL}/leaderboard`, { - credentials: 'include' - }); - - if (response.ok) { - const data = await response.json(); - setLeaderboard(data); - setError(null); - } else if (response.status === 401) { - setError('Please sign in to view the leaderboard'); - } - } catch (error) { - console.error('Error:', error); - setError('Failed to load leaderboard data'); - } - }; + fetchAllData(); - useEffect(() => { - checkOptInStatus(); - fetchLeaderboard(); - fetchCommunityXP(); - }, []); + return () => { + mounted = false; + setLeaderboardState(prev => ({ ...prev, isLoading: false, data: [] })); + }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + const handleOptInToggle = () => leaderboardManager.handleOptInToggle(); + const getLeaderboardMetrics = () => leaderboardManager.getLeaderboardMetrics(leaderboardState.data); + const handleClose = () => { + setLeaderboardState(prev => ({ ...prev, isLoading: false })); + onClose?.(); + }; - if (error) { + if (leaderboardState.error) { return (

    @@ -182,69 +152,108 @@ const Leaderboard = ({ limit, className, scrollUsers = false, onShowFull }) => {

    - {error} + {leaderboardState.error}

    ); } - return ( -
    -
    -
    -

    - Leaderboard -

    -
    - Total User XP: {communityXP.toLocaleString()} -
    -
    - {scrollUsers ? ( -
    + if (scrollUsers) { + return ( +
    + {/* Header */} +
    +
    +
    +

    + Leaderboard +

    +
    - {showTooltip && ( -
    - {isOptedIn - ? 'Click to remove your name and stats from the leaderboard' - : 'Click to share your name and stats publicly on the leaderboard (can opt-out anytime!)' - } -
    - )}
    - ) : ( - - )} + + {leaderboardState.isLoading ? ( + + ) : ( + getLeaderboardMetrics() && ( +
    + {/* opt-in control with tooltip */} +
    +
    + + {showTooltip && ( +
    + {leaderboardState.isOptedIn + ? 'Click to remove your name and stats from the leaderboard' + : 'Click to share your name and stats publicly on the leaderboard' + } +
    + )} +
    +
    + + {/* metric boxes */} + + + +
    + ) + )} +
    + + {/* User List */} +
    + {leaderboardState.isLoading ? ( + + ) : ( +
      + {leaderboardState.data.map((user, index) => ( + + ))} +
    + )} +
    -
      - {leaderboard.slice(0, limit || leaderboard.length).map((user) => ( - - ))} - {leaderboard.length === 0 && ( -
    • - No users in leaderboard yet -
    • - )} -
    -
    + ); + } + + return ( +
    +
    +
    +

    Leaderboard

    +
    +
    +

    Total User XP: {leaderboardState.communityXP.toLocaleString()}

    {onShowFull && ( )}
    {/* Consistent card styling for limited list view */}
      {leaderboardState.data.slice(0, limit || leaderboardState.data.length).map((user) => (
    • {user?.picture && ( Profile )} {user?.name || 'Anonymous User'}
      {user?.xp || 0} XP
    • ))} {leaderboardState.data.length === 0 && (
    • No users in leaderboard yet
    • )}
    +
    ); }; diff --git a/client/src/components/Streak Management/StreakTracker.js b/client/src/components/Streak Management/StreakTracker.js index b24aff3..11bde3b 100644 --- a/client/src/components/Streak Management/StreakTracker.js +++ b/client/src/components/Streak Management/StreakTracker.js @@ -1,43 +1,14 @@ -import React, { useEffect, useState } from 'react'; -import XPProgressionChart from '../../user analytics/graph/XPProgressionChart'; +import React, { useCallback } from 'react'; +import Dashboard from '../Analytics/Dashboard'; +import { ChartBarIcon } from '@heroicons/react/24/outline'; -const StreakTracker = ({ completedTasks, streakData }) => { - const [xpData, setXpData] = useState(null); - - // Keep only XP data calculation in component - useEffect(() => { - if (!completedTasks.length) { - setXpData(null); - return; - } - - // Group tasks by date and sum XP - const xpByDate = completedTasks.reduce((acc, task) => { - if (!task.completedAt) return acc; - - const date = new Date(task.completedAt).toLocaleDateString(); - const xp = (task.experience || 0) + (task.earlyBonus || 0) + (task.overduePenalty || 0); - - acc[date] = (acc[date] || 0) + xp; - return acc; - }, {}); - // Get last 7 days of data - const dates = Object.keys(xpByDate).sort().slice(-7); - const xpValues = dates.map(date => xpByDate[date]); +const StreakTracker = ({ completedTasks, streakData }) => { + const [openDashboard, setOpenDashboard] = React.useState(null); - setXpData({ - labels: dates.map(date => new Date(date).toLocaleDateString('en-US', { weekday: 'short' })), - datasets: [{ - label: 'XP Gained', - data: xpValues, - fill: false, - borderColor: '#60A5FA', - tension: 0.3, - pointBackgroundColor: '#60A5FA' - }] - }); - }, [completedTasks]); + const handleOpenDashboard = useCallback((opener) => { + setOpenDashboard(() => opener); + }, []); return (
    @@ -51,13 +22,28 @@ const StreakTracker = ({ completedTasks, streakData }) => {

    {streakData.longest}

    - - {/* XP Graph Section */}
    -

    - XP Progression (Last 7 Days) -

    - +
    +
    +

    XP Growth

    +

    Past 7 days

    +
    + +
    +
    ); diff --git a/client/src/services/analytics/DashboardManager.js b/client/src/services/analytics/DashboardManager.js new file mode 100644 index 0000000..512bfb4 --- /dev/null +++ b/client/src/services/analytics/DashboardManager.js @@ -0,0 +1,273 @@ +class DashboardManager { + constructor() { + // Enhanced cache with timestamp + this.cache = new Map(); + this.cacheTimeout = 5 * 60 * 1000; // 5 minutes + + // Constants + this.CHART_COLORS = { + primary: '#60A5FA', + background: 'rgba(96, 165, 250, 0.5)' + }; + + // Batch size for processing large datasets + this.BATCH_SIZE = 100; + } + + // Improved cache handling with timeout + getCacheItem(key) { + const item = this.cache.get(key); + if (!item) return null; + + if (Date.now() - item.timestamp > this.cacheTimeout) { + this.cache.delete(key); + return null; + } + return item.value; + } + + setCacheItem(key, value) { + this.cache.set(key, { + value, + timestamp: Date.now() + }); + } + + // Optimized date helpers + getDateRange() { + const cacheKey = 'dateRange'; + const cached = this.getCacheItem(cacheKey); + if (cached) return cached; + + const result = { + startDate: new Date(), + endDate: new Date() + }; + + result.startDate.setDate(result.startDate.getDate() - 7); + result.startDate.setHours(0, 0, 0, 0); + result.endDate.setDate(result.endDate.getDate() - 1); + result.endDate.setHours(23, 59, 59, 999); + + this.setCacheItem(cacheKey, result); + return result; + } + + calculateLast7Days() { + const days = []; + const { startDate } = this.getDateRange(); + + // Generate 7 days from startDate + for (let i = 0; i < 7; i++) { + const d = new Date(startDate); + d.setDate(startDate.getDate() + i); + const month = (d.getMonth() + 1).toString().padStart(2, '0'); + const day = d.getDate().toString().padStart(2, '0'); + days.push({ + date: d, + label: `${month}/${day}` + }); + } + return days; + } + + // Helper to normalize date for consistent comparison using UTC + normalizeDate(date, taskTimezone) { + const d = new Date(date); + // Use the timezone from when the task was completed + const localDate = new Date(d.toLocaleString('en-US', { + timeZone: taskTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone + })); + localDate.setHours(0, 0, 0, 0); + return localDate.getTime(); + } + + // Helper to calculate task XP + getTaskXP(task) { + const baseXP = task.experience || 0; + const bonus = task.earlyBonus || 0; + const penalty = task.overduePenalty || 0; + return baseXP + bonus + penalty; + } + + // Optimized task processing + processTasks(tasks, startDate, endDate) { + if (!tasks?.length) return new Map(); + + const tasksByDate = new Map(); + const batches = Math.ceil(tasks.length / this.BATCH_SIZE); + + for (let i = 0; i < batches; i++) { + const batchTasks = tasks.slice(i * this.BATCH_SIZE, (i + 1) * this.BATCH_SIZE); + + batchTasks.forEach(task => { + const taskDate = new Date(task.completedAt); + if (taskDate >= startDate && taskDate <= endDate) { + const normalizedDate = this.normalizeDate(task.completedAt, task.completedTimezone); + if (!tasksByDate.has(normalizedDate)) { + tasksByDate.set(normalizedDate, []); + } + tasksByDate.get(normalizedDate).push(task); + } + }); + } + + return tasksByDate; + } + + calculateXPData(completedTasks) { + if (!completedTasks?.length) return null; + + const cacheKey = `xpData-${completedTasks.length}-${completedTasks[0].completedAt}-${completedTasks[completedTasks.length-1].completedAt}`; + const cached = this.getCacheItem(cacheKey); + if (cached) return cached; + + const { startDate, endDate } = this.getDateRange(); + const tasksByDate = this.processTasks(completedTasks, startDate, endDate); + const last7Days = this.calculateLast7Days(); + + // Calculate daily XP + const dailyXP = last7Days.map(day => { + const normalizedDate = this.normalizeDate(day.date); + const tasksForDay = tasksByDate.get(normalizedDate) || []; + return { + label: day.label, + xp: tasksForDay.reduce((sum, task) => sum + this.getTaskXP(task), 0) + }; + }); + + const result = { + labels: dailyXP.map(d => d.label), + datasets: [{ + label: 'XP Gained', + data: dailyXP.map(d => d.xp), + fill: false, + borderColor: this.CHART_COLORS.primary, + tension: 0.3, + pointBackgroundColor: this.CHART_COLORS.primary + }] + }; + + this.setCacheItem(cacheKey, result); + return result; + } + + calculatePeriodXP(completedTasks) { + if (!completedTasks?.length) return 0; + + const cacheKey = `periodXP-${completedTasks.map(t => t.completedAt).join('-')}`; + const cached = this.getCacheItem(cacheKey); + if (cached) return cached; + + const { startDate, endDate } = this.getDateRange(); + + const result = completedTasks + .filter(task => { + const taskDate = new Date(task.completedAt); + return taskDate >= startDate && taskDate <= endDate; + }) + .reduce((total, task) => total + this.getTaskXP(task), 0); + + this.setCacheItem(cacheKey, result); + return result; + } + + calculateAverageDaily(completedTasks, xpData) { + if (!completedTasks?.length || !xpData?.labels) return 0; + + const { startDate, endDate } = this.getDateRange(); + + const periodTasks = completedTasks.filter(task => { + const taskDate = new Date(task.completedAt); + return taskDate >= startDate && taskDate <= endDate; + }); + + const totalXP = periodTasks.reduce((sum, task) => + sum + this.getTaskXP(task), 0 + ); + + return Math.round(totalXP / 7); + } + + findPeakDay(xpData) { + if (!xpData?.labels) return null; + const data = xpData.datasets[0].data; + const maxXP = Math.max(...data); + const peakIndex = data.indexOf(maxXP); + const [month, day] = xpData.labels[peakIndex].split('/'); + const date = new Date(new Date().getFullYear(), Number(month) - 1, Number(day)); + return { + date: date.toLocaleDateString('en-US', { weekday: 'short' }), + xp: maxXP + }; + } + + getCompletedTasksData(completedTasks, xpData) { + if (!completedTasks?.length || !xpData?.labels) return null; + + const daysMap = this.calculateLast7Days().reduce((acc, day) => { + acc[day.label] = this.normalizeDate(day.date); + return acc; + }, {}); + + const taskCounts = xpData.labels.reduce((acc, date) => { + const normalizedDate = daysMap[date]; + acc[date] = completedTasks.filter(task => + this.normalizeDate(task.completedAt) === normalizedDate + ).length; + return acc; + }, {}); + + return { + labels: xpData.labels, + datasets: [{ + label: 'Tasks Completed', + data: Object.values(taskCounts), + backgroundColor: this.CHART_COLORS.background, + borderColor: this.CHART_COLORS.primary, + borderWidth: 1, + borderRadius: 5, + }] + }; + } + + getMetrics(xpData, periodXP) { + if (!xpData?.datasets?.[0]?.data) return null; + const data = xpData.datasets[0].data; + + const firstHalf = data.slice(0, 3).reduce((sum, val) => sum + val, 0) / 3; + const secondHalf = data.slice(-3).reduce((sum, val) => sum + val, 0) / 3; + + const trendPercentage = firstHalf === 0 ? 0 : ((secondHalf - firstHalf) / firstHalf * 100); + const daysWithActivity = data.filter(xp => xp > 0).length; + + return { + weeklyXP: periodXP, + trendDirection: secondHalf >= firstHalf ? 'Improving' : 'Decreasing', + trendPercentage: Math.abs(Math.round(trendPercentage)), + activeDays: `${daysWithActivity}/7 days`, + trendDescription: this.getTrendDescription(trendPercentage) + }; + } + + getTrendDescription(percentage) { + if (percentage === 0) return 'Maintaining'; + if (percentage > 0) { + if (percentage > 100) return 'Significant growth'; + if (percentage > 50) return 'Strong growth'; + if (percentage > 20) return 'Steady growth'; + return 'Slight growth'; + } else { + if (percentage < -100) return 'Sharp decline'; + if (percentage < -50) return 'Significant decline'; + if (percentage < -20) return 'Moderate decline'; + return 'Slight decline'; + } + } + + clearCache() { + this.cache.clear(); + } +} + +export default DashboardManager; diff --git a/client/src/services/leaderboard/LeaderboardManager.js b/client/src/services/leaderboard/LeaderboardManager.js new file mode 100644 index 0000000..917223a --- /dev/null +++ b/client/src/services/leaderboard/LeaderboardManager.js @@ -0,0 +1,110 @@ +const API_BASE_URL = process.env.REACT_APP_PROD || 'http://localhost:3001/api'; + +class LeaderboardManager { + constructor(setLeaderboard, setError, setIsOptedIn, setCommunityXP, setIsLoading) { + this.setLeaderboard = setLeaderboard; + this.setError = setError; + this.setIsOptedIn = setIsOptedIn; + this.setCommunityXP = setCommunityXP; + this.setIsLoading = setIsLoading; + } + + async fetchCommunityXP() { + try { + const response = await fetch(`${API_BASE_URL}/analytics`, { + credentials: 'include' + }); + + if (response.ok) { + const data = await response.json(); + this.setCommunityXP(data.communityXP); + } + } catch (error) { + console.error('Error fetching community XP:', error); + } + } + + async checkOptInStatus() { + try { + const response = await fetch(`${API_BASE_URL}/auth/current_user`, { + credentials: 'include' + }); + + if (response.ok) { + const userData = await response.json(); + this.setIsOptedIn(userData.isOptIn || false); + } else if (response.status === 401) { + this.setError('Please sign in to view the leaderboard'); + } + } catch (error) { + console.error('Error checking opt-in status:', error); + } + } + + async handleOptInToggle() { + try { + const userResponse = await fetch(`${API_BASE_URL}/auth/current_user`, { + credentials: 'include' + }); + + if (!userResponse.ok) { + throw new Error('Not authenticated'); + } + + const userData = await userResponse.json(); + + const response = await fetch(`${API_BASE_URL}/users/${userData.userId}/opt-in`, { + method: 'PUT', + credentials: 'include' + }); + + if (response.ok) { + const data = await response.json(); + this.setIsOptedIn(data.isOptIn); + this.fetchLeaderboard(); + } else if (response.status === 401) { + this.setError('Please sign in to change opt-in status'); + } + } catch (error) { + console.error('Error toggling opt-in status:', error); + } + } + + async fetchLeaderboard() { + try { + this.setIsLoading(true); + const response = await fetch(`${API_BASE_URL}/leaderboard`, { + credentials: 'include' + }); + + if (response.ok) { + const data = await response.json(); + this.setLeaderboard(data); + this.setError(null); + } else if (response.status === 401) { + this.setError('Please sign in to view the leaderboard'); + } + } catch (error) { + console.error('Error:', error); + this.setError('Failed to load leaderboard data'); + } finally { + this.setIsLoading(false); + } + } + + getLeaderboardMetrics(leaderboard) { + if (!leaderboard.length) return null; + + const topPerformer = leaderboard[0]; + const totalTasks = leaderboard.reduce((sum, user) => sum + (user.tasksCompleted || 0), 0); + const highestLevel = Math.max(...leaderboard.map(user => user.level || 1)); + + return { + topScore: topPerformer?.xp || 0, + totalTasks, + highestLevel + }; + } +} + +export default LeaderboardManager; diff --git a/client/src/services/task/TaskManager.js b/client/src/services/task/TaskManager.js index 9ea7c53..baf398b 100644 --- a/client/src/services/task/TaskManager.js +++ b/client/src/services/task/TaskManager.js @@ -40,7 +40,8 @@ class TaskManager { const completedTask = { ...task, - completedAt: new Date().toISOString() + completedAt: new Date().toISOString(), + completedTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone }; const xpResult = this.calculateXP(task.experience, task.deadline); diff --git a/client/src/user analytics/graph/XPProgressionChart.js b/client/src/user analytics/graph/XPProgressionChart.js deleted file mode 100644 index 2c31a39..0000000 --- a/client/src/user analytics/graph/XPProgressionChart.js +++ /dev/null @@ -1,172 +0,0 @@ -import React from 'react'; -import { Line } from 'react-chartjs-2'; -import { - Chart as ChartJS, - CategoryScale, - LinearScale, - PointElement, - LineElement, - Title, - Tooltip, - Legend -} from 'chart.js'; - -ChartJS.register( - CategoryScale, - LinearScale, - PointElement, - LineElement, - Title, - Tooltip, - Legend -); - -const XPProgressionChart = ({ xpData }) => { - const chartOptions = { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - display: false - }, - tooltip: { - backgroundColor: '#1F2937', - titleColor: '#F3F4F6', - bodyColor: '#F3F4F6', - displayColors: false, - callbacks: { - label: (context) => `${context.parsed.y} XP` - } - } - }, - scales: { - y: { - beginAtZero: true, - ticks: { - color: '#6B7280' - }, - grid: { - display: false - } - }, - x: { - ticks: { - color: '#6B7280' - }, - grid: { - display: false - } - } - } - }; - - const calculateTotalXPGain = () => { - if (!xpData || !xpData.datasets || !xpData.datasets[0].data) return 0; - const data = xpData.datasets[0].data; - return data[data.length - 1] - data[0]; - }; - - const renderXPStatus = () => { - const gain = calculateTotalXPGain(); - if (gain === 0) { - return "No XP change"; - } - return `+${gain} XP gain`; - }; - - const findPeakDay = () => { - if (!xpData || !xpData.datasets || !xpData.datasets[0].data) return null; - const data = xpData.datasets[0].data; - const labels = xpData.labels; - let maxGain = 0; - let peakDay = null; - - for (let i = 1; i < data.length; i++) { - const dayGain = data[i] - data[i-1]; - if (dayGain > maxGain) { - maxGain = dayGain; - peakDay = labels[i]; - } - } - - return { date: peakDay, xp: maxGain }; - }; - - const calculateAverageDaily = () => { - if (!xpData || !xpData.datasets || !xpData.datasets[0].data) return 0; - const data = xpData.datasets[0].data; - let totalDailyGains = 0; - let days = 0; - - for (let i = 1; i < data.length; i++) { - const dayGain = data[i] - data[i-1]; - if (dayGain > 0) { - totalDailyGains += dayGain; - days++; - } - } - - return days > 0 ? Math.round(totalDailyGains / days) : 0; - }; - - const renderPeakDay = () => { - const peak = findPeakDay(); - if (!peak || peak.xp === 0) { - return "No activity yet"; - } - return `${peak.date} • ${peak.xp}XP`; - }; - - return ( -
    -
    - {xpData ? ( - - ) : ( -
    - No XP data available -
    - )} -
    - {xpData && ( -
    -
    -
    - Period XP - - {renderXPStatus()} - -
    -
    -
    -
    - Peak Day - - {renderPeakDay()} - -
    -
    -
    -
    - Average Daily - - {calculateAverageDaily()} XP per day - -
    -
    -
    - )} -
    - ); -}; - -export default XPProgressionChart;