-
-
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
+
+
setShowTooltip(true)}
- onMouseLeave={() => setShowTooltip(false)}
- onClick={handleOptInToggle}
- className="p-2 rounded-lg bg-white dark:bg-gray-800 border-2 border-gray-800
- shadow-[2px_2px_#2563EB] hover:shadow-none hover:translate-x-0.5
- hover:translate-y-0.5 transition-all duration-200
- text-gray-800 dark:text-white"
+ onClick={handleClose}
+ className="w-8 h-8 rounded-lg flex items-center justify-center bg-red-500/10 hover:bg-red-500/20 transition-colors"
>
- {isOptedIn ? 'Opt Out' : 'Opt In'}
+ ×
- {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!)'
- }
-
- )}
- ) : (
-
- View All
-
- )}
+
+ {leaderboardState.isLoading ? (
+
+ ) : (
+ getLeaderboardMetrics() && (
+
+ {/* opt-in control with tooltip */}
+
+
+
setShowTooltip(true)}
+ onMouseLeave={() => setShowTooltip(false)}
+ onClick={handleOptInToggle}
+ className="w-full text-sm font-medium px-4 py-2.5 rounded-lg
+ bg-white dark:bg-gray-800 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"
+ >
+ {leaderboardState.isOptedIn ? 'Opt-Out' : 'Opt-In'}
+
+ {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 && (
View All )}
{/* Consistent card styling for limited list view */}
{leaderboardState.data.slice(0, limit || leaderboardState.data.length).map((user) => ( {user?.picture && (
)}
{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
+
+
openDashboard?.()}
+ 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"
+ >
+
+ Analytics
+
+
+
);
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;