Skip to content

Commit

Permalink
Merge pull request #178 from hussaino03/develop
Browse files Browse the repository at this point in the history
Fixes #176: Analytics Dashboard
  • Loading branch information
hussaino03 authored Dec 21, 2024
2 parents 1f75418 + 7037c65 commit 0f18adb
Show file tree
Hide file tree
Showing 8 changed files with 321 additions and 147 deletions.
277 changes: 191 additions & 86 deletions client/src/components/Analytics/Dashboard.js

Large diffs are not rendered by default.

54 changes: 48 additions & 6 deletions client/src/components/Leaderboard/Leaderboard.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useState, useEffect, memo } from 'react';
import { ChartBarIcon } from '@heroicons/react/24/outline';
import { PresentationChartLineIcon } from '@heroicons/react/24/outline';
import LeaderboardManager from '../../services/leaderboard/LeaderboardManager';

const LoadingSpinner = memo(() => (
Expand All @@ -26,7 +26,7 @@ const UserListItem = memo(({ user, index, showDetails, setShowDetails }) => (
<li className="group hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors duration-200">
<div className="px-3 sm:px-6 py-3 sm:py-4">
<div className="flex items-center space-x-4">
{/* Position indicator */}
{/* Position */}
<div className="flex-shrink-0 w-8 text-center">
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
#{index + 1}
Expand Down Expand Up @@ -183,7 +183,7 @@ const Leaderboard = ({ limit, className, scrollUsers = false, onShowFull, onClos
) : (
getLeaderboardMetrics() && (
<div className="mt-3 sm:mt-4 space-y-3 sm:space-y-0 sm:grid sm:grid-cols-2 lg:grid-cols-4 gap-3">
{/* opt-in control with tooltip */}
{/* opt-in */}
<div className="p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 border border-gray-100
dark:border-gray-600 flex flex-col justify-center">
<div className="relative flex items-center justify-center">
Expand Down Expand Up @@ -250,10 +250,52 @@ const Leaderboard = ({ limit, className, scrollUsers = false, onShowFull, onClos
<div className="flex justify-between items-center mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-100">Leaderboard</h3>
<div className="flex items-center space-x-1.5 mt-1">
<div className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse"></div>
<p className="text-sm text-gray-500 dark:text-gray-400">Total User XP: {leaderboardState.communityXP.toLocaleString()} </p> </div> </div> {onShowFull && ( <button onClick={onShowFull} className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white dark:bg-gray-800 font-medium text-sm border-2 border-gray-800 text-gray-800 dark:text-gray-200 shadow-[2px_2px_#2563EB] hover:shadow-none hover:translate-x-0.5 hover:translate-y-0.5 transition-all duration-200" > <ChartBarIcon className="w-4 h-4 text-gray-900 dark:text-white" /> View All </button> )} </div> {/* Consistent card styling for limited list view */} <div className="bg-gray-50 dark:bg-gray-700/50 rounded-xl border border-gray-100 dark:border-gray-600 overflow-hidden"> <ul className="divide-y divide-gray-200 dark:divide-gray-600"> {leaderboardState.data.slice(0, limit || leaderboardState.data.length).map((user) => ( <li key={user._id} className="p-4"> <div className="flex items-center justify-between"> <div className="flex items-center space-x-3"> {user?.picture && ( <img src={user.picture} alt="Profile" className="w-8 h-8 rounded-full" /> )} <span className="font-medium text-gray-900 dark:text-gray-100"> {user?.name || 'Anonymous User'} </span> </div> <span className="text-gray-600 dark:text-gray-400"> {user?.xp || 0} XP </span> </div> </li> ))} {leaderboardState.data.length === 0 && ( <li className="p-4 text-gray-500 dark:text-gray-400 text-center"> No users in leaderboard yet </li> )} </ul> </div>
<div className="flex items-center space-x-1.5 mt-1">
<div className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse"></div>
<p className="text-sm text-gray-500 dark:text-gray-400">Total User XP: {leaderboardState.communityXP.toLocaleString()}</p>
</div>
</div>
{onShowFull && (
<button
onClick={onShowFull}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white dark:bg-gray-800 font-medium text-sm
border-2 border-gray-800 text-gray-800 dark:text-gray-200
shadow-[2px_2px_#2563EB] hover:shadow-none hover:translate-x-0.5
hover:translate-y-0.5 transition-all duration-200"
>
<PresentationChartLineIcon className="w-4 h-4 text-gray-900 dark:text-white" />
View All
</button>
)}
</div>

<div className="bg-gray-50 dark:bg-gray-700/50 rounded-xl border border-gray-100 dark:border-gray-600 overflow-hidden">
<ul className="divide-y divide-gray-200 dark:divide-gray-600">
{leaderboardState.data.slice(0, limit || leaderboardState.data.length).map((user) => (
<li key={user._id} className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
{user?.picture && (
<img src={user.picture} alt="Profile" className="w-8 h-8 rounded-full" />
)}
<span className="font-medium text-gray-900 dark:text-gray-100">
{user?.name || 'Anonymous User'}
</span>
</div>
<span className="text-gray-600 dark:text-gray-400">
{user?.xp || 0} XP
</span>
</div>
</li>
))}
{leaderboardState.data.length === 0 && (
<li className="p-4 text-gray-500 dark:text-gray-400 text-center">
No users in leaderboard yet
</li>
)}
</ul>
</div>
</div>
);
};

Expand Down
8 changes: 3 additions & 5 deletions client/src/components/Modal Management/List.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const TaskList = ({ tasks = [], removeTask, completeTask, isCompleted, addTask,
const [isCalendarView, setIsCalendarView] = useState(false);
const [activeTab, setActiveTab] = useState('tasks');
const [showAllCompleted, setShowAllCompleted] = useState(false);
const [sortMethod, setSortMethod] = useState('date'); // 'date' or 'label'
const [sortMethod, setSortMethod] = useState('date');

const handleQuickAdd = (e) => {
if (e.key === 'Enter' && quickTaskInput.trim()) {
Expand Down Expand Up @@ -308,9 +308,7 @@ const TaskList = ({ tasks = [], removeTask, completeTask, isCompleted, addTask,
{/* Completed tasks modal */}
{showAllCompleted && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[100] flex items-center justify-center p-4 animate-fadeIn">
<div className="bg-white dark:bg-gray-800 rounded-lg w-full max-w-2xl max-h-[80vh] overflow-y-auto
scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600
scrollbar-track-transparent">
<div className="bg-white dark:bg-gray-800 rounded-lg w-full max-w-2xl h-[80vh] flex flex-col">
<div className="flex justify-end items-center p-4 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<button
onClick={() => setShowAllCompleted(false)}
Expand All @@ -319,7 +317,7 @@ const TaskList = ({ tasks = [], removeTask, completeTask, isCompleted, addTask,
<span className="text-red-600 dark:text-red-400 text-lg">×</span>
</button>
</div>
<div className="p-4">
<div className="p-4 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600 scrollbar-track-transparent flex-1">
{sortedGroups.map(([date, dateTasks]) => (
<div key={date} className="w-full flex flex-col items-center space-y-2">
<div className="w-11/12 max-w-2xl text-sm text-gray-500 dark:text-gray-400">
Expand Down
5 changes: 1 addition & 4 deletions client/src/components/Streak Management/StreakTracker.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,7 @@ const StreakTracker = ({ completedTasks, streakData }) => {
</div>
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<div className="flex justify-between items-center mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-100">XP Growth</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Past 7 days</p>
</div>
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-100">XP Growth</h3>
<button
onClick={() => openDashboard?.()}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,18 +68,18 @@ describe('StreakTracker Component', () => {
expect(screen.getByText('Longest Streak').nextElementSibling).toHaveTextContent('0');
});

it('renders chart with completed tasks data', () => {
it('shows XP Growth section with completed tasks data', () => {
const mockCompletedTasks = [
{ completedAt: '2023-05-15T10:00:00Z', experience: 100 },
{ completedAt: '2023-05-14T10:00:00Z', experience: 150 }
{ completedAt: new Date().toISOString(), experience: 100 },
{ completedAt: new Date().toISOString(), experience: 150 }
];

renderStreakTracker({
completedTasks: mockCompletedTasks,
streakData: { current: 2, longest: 2 }
});

expect(screen.getByTestId('xp-chart')).toBeInTheDocument();
expect(screen.getByText('XP Growth')).toBeInTheDocument();
});

it('shows no data message when no completed tasks', () => {
Expand All @@ -91,9 +91,24 @@ describe('StreakTracker Component', () => {
expect(screen.getByText('No XP data available')).toBeInTheDocument();
});

it('shows XP progression header', () => {
it('shows Analytics button', () => {
renderStreakTracker();
expect(screen.getByText('Analytics')).toBeInTheDocument();
});

it('shows metrics when XP data is available', () => {
const mockCompletedTasks = [
{ completedAt: new Date().toISOString(), experience: 100 },
{ completedAt: new Date().toISOString(), experience: 150 }
];

renderStreakTracker({
completedTasks: mockCompletedTasks,
streakData: { current: 2, longest: 2 }
});

expect(screen.getByText('XP Progression (Last 7 Days)')).toBeInTheDocument();
expect(screen.getByText('Analytics')).toBeInTheDocument();
expect(screen.getByText(/Peak Day/)).toBeInTheDocument();
expect(screen.getByText(/Average Daily/)).toBeInTheDocument();
});
});
70 changes: 31 additions & 39 deletions client/src/services/analytics/DashboardManager.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,16 @@
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;
Expand All @@ -33,9 +29,8 @@ class DashboardManager {
});
}

// Optimized date helpers
getDateRange() {
const cacheKey = 'dateRange';
getDateRange(days = 7) {
const cacheKey = `dateRange-${days}`;
const cached = this.getCacheItem(cacheKey);
if (cached) return cached;

Expand All @@ -44,53 +39,47 @@ class DashboardManager {
endDate: new Date()
};

result.startDate.setDate(result.startDate.getDate() - 7);
result.startDate.setDate(result.startDate.getDate() - (days - 1));
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();
calculateDays(days = 7) {
const daysArray = [];
const { startDate } = this.getDateRange(days);

// Generate 7 days from startDate
for (let i = 0; i < 7; i++) {
for (let i = 0; i < days; 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({
daysArray.push({
date: d,
label: `${month}/${day}`
});
}
return days;
return daysArray;
}

// 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();

Expand All @@ -115,19 +104,18 @@ class DashboardManager {
return tasksByDate;
}

calculateXPData(completedTasks) {
calculateXPData(completedTasks, days = 7) {
if (!completedTasks?.length) return null;

const cacheKey = `xpData-${completedTasks.length}-${completedTasks[0].completedAt}-${completedTasks[completedTasks.length-1].completedAt}`;
const cacheKey = `xpData-${days}-${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 { startDate, endDate } = this.getDateRange(days);
const tasksByDate = this.processTasks(completedTasks, startDate, endDate);
const last7Days = this.calculateLast7Days();
const periodDays = this.calculateDays(days);

// Calculate daily XP
const dailyXP = last7Days.map(day => {
const dailyXP = periodDays.map(day => {
const normalizedDate = this.normalizeDate(day.date);
const tasksForDay = tasksByDate.get(normalizedDate) || [];
return {
Expand All @@ -152,14 +140,14 @@ class DashboardManager {
return result;
}

calculatePeriodXP(completedTasks) {
calculatePeriodXP(completedTasks, days = 7) {
if (!completedTasks?.length) return 0;

const cacheKey = `periodXP-${completedTasks.map(t => t.completedAt).join('-')}`;
const cacheKey = `periodXP-${days}-${completedTasks.map(t => t.completedAt).join('-')}`;
const cached = this.getCacheItem(cacheKey);
if (cached) return cached;

const { startDate, endDate } = this.getDateRange();
const { startDate, endDate } = this.getDateRange(days);

const result = completedTasks
.filter(task => {
Expand All @@ -172,10 +160,10 @@ class DashboardManager {
return result;
}

calculateAverageDaily(completedTasks, xpData) {
calculateAverageDaily(completedTasks, xpData, days = 7) {
if (!completedTasks?.length || !xpData?.labels) return 0;

const { startDate, endDate } = this.getDateRange();
const { startDate, endDate } = this.getDateRange(days);

const periodTasks = completedTasks.filter(task => {
const taskDate = new Date(task.completedAt);
Expand All @@ -186,7 +174,7 @@ class DashboardManager {
sum + this.getTaskXP(task), 0
);

return Math.round(totalXP / 7);
return Math.round(totalXP / days);
}

findPeakDay(xpData) {
Expand All @@ -202,10 +190,10 @@ class DashboardManager {
};
}

getCompletedTasksData(completedTasks, xpData) {
getCompletedTasksData(completedTasks, xpData, days = 7) {
if (!completedTasks?.length || !xpData?.labels) return null;

const daysMap = this.calculateLast7Days().reduce((acc, day) => {
const daysMap = this.calculateDays(days).reduce((acc, day) => {
acc[day.label] = this.normalizeDate(day.date);
return acc;
}, {});
Expand All @@ -231,22 +219,26 @@ class DashboardManager {
};
}

getMetrics(xpData, periodXP) {
getMetrics(xpData, periodXP, days = 7) {
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 segmentSize = Math.floor(days / 3);
const firstHalf = data.slice(0, segmentSize).reduce((sum, val) => sum + val, 0) / segmentSize;
const secondHalf = data.slice(-segmentSize).reduce((sum, val) => sum + val, 0) / segmentSize;

const trendPercentage = firstHalf === 0 ? 0 : ((secondHalf - firstHalf) / firstHalf * 100);
const daysWithActivity = data.filter(xp => xp > 0).length;

const periodLabel = days === 7 ? '7-Day' : '30-Day';

return {
weeklyXP: periodXP,
trendDirection: secondHalf >= firstHalf ? 'Improving' : 'Decreasing',
trendPercentage: Math.abs(Math.round(trendPercentage)),
activeDays: `${daysWithActivity}/7 days`,
trendDescription: this.getTrendDescription(trendPercentage)
activeDays: `${daysWithActivity}/${days} days`,
trendDescription: this.getTrendDescription(trendPercentage),
periodLabel: `${periodLabel} XP Total`
};
}

Expand Down
Loading

0 comments on commit 0f18adb

Please sign in to comment.