diff --git a/client/src/App.js b/client/src/App.js
index 90c0965..4bd85a1 100644
--- a/client/src/App.js
+++ b/client/src/App.js
@@ -73,11 +73,18 @@ const AppContent = () => {
resetXP
});
+ const { addNotification } = useNotification();
+ const badgeManager = useMemo(
+ () => new BadgeManager(setUnlockedBadges, addNotification),
+ [addNotification]
+ );
+
const taskManager = new TaskManager(
calculateXP,
setTasks,
setCompletedTasks,
- setError
+ setError,
+ badgeManager
);
const themeManager = useMemo(() => new ThemeManager(setIsDark), []);
@@ -86,11 +93,6 @@ const AppContent = () => {
() => new ViewManager(setShowCompleted, setCurrentView),
[]
);
- const { addNotification } = useNotification();
- const badgeManager = useMemo(
- () => new BadgeManager(setUnlockedBadges, addNotification),
- [addNotification]
- );
const collaborationManager = useMemo(
() => new CollaborationManager(setTasks, setError),
[]
@@ -122,13 +124,17 @@ const AppContent = () => {
}, [userId, tasks, completedTasks, experience, level, unlockedBadges]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
- badgeManager.checkAndUpdateBadges(
+ const updatedBadges = badgeManager.checkForNewBadges(
level,
currentStreak,
completedTasks,
unlockedBadges
);
- }, [level, currentStreak, completedTasks, unlockedBadges, badgeManager]);
+
+ if (updatedBadges.length !== unlockedBadges.length) {
+ setUnlockedBadges(updatedBadges);
+ }
+ }, [level, currentStreak]);
useEffect(() => {
const newStreakData = streakManager.calculateStreak(completedTasks);
@@ -188,6 +194,7 @@ const AppContent = () => {
};
const handleConfirmClear = async () => {
+ badgeManager.clearNotificationHistory();
await dataManager.clearAllData(userId);
setShowClearDataModal(false);
};
diff --git a/client/src/components/Badge/BadgeGrid.js b/client/src/components/Badge/BadgeGrid.js
index ac14284..6c6a21b 100644
--- a/client/src/components/Badge/BadgeGrid.js
+++ b/client/src/components/Badge/BadgeGrid.js
@@ -1,221 +1,221 @@
-import React, { useState } from 'react';
-import BadgesModal from './BadgesModal';
-import { Trophy } from 'lucide-react';
-
-export const BADGES = {
- NOVICE: {
- id: 'novice',
- name: 'Novice',
- icon: '🌟',
- description: 'Reach level 5',
- level: 5
- },
- INTERMEDIATE: {
- id: 'intermediate',
- name: 'Intermediate',
- icon: '⭐',
- description: 'Reach level 10',
- level: 10
- },
- XP_HUNTER: {
- id: 'xp_hunter',
- name: 'XP Hunter',
- icon: '💫',
- description: 'Reach level 15',
- level: 15
- },
- STREAK_MASTER: {
- id: 'streak_master',
- name: 'Consistent',
- icon: '🔥',
- description: 'Reach a 5-day streak',
- streakRequired: 5
- },
- TASK_ACHIEVER: {
- id: 'task_achiever',
- name: 'Achiever',
- icon: '✅',
- description: 'Complete 20 tasks',
- tasksRequired: 20
- },
- TASK_MASTER: {
- id: 'task_master',
- name: 'Task Master',
- icon: '👑',
- description: 'Complete 50 tasks',
- tasksRequired: 50
- },
- DEDICATION: {
- id: 'dedication',
- name: 'Dedication',
- icon: '💪',
- description: 'Reach a 10-day streak',
- streakRequired: 10
- },
- ELITE: {
- id: 'elite',
- name: 'Elite',
- icon: '🏆',
- description: 'Reach level 25',
- level: 25
- },
- LEGENDARY: {
- id: 'legendary',
- name: 'Legendary',
- icon: '⚡',
- description: 'Complete 100 tasks',
- tasksRequired: 100
- },
- UNSTOPPABLE: {
- id: 'unstoppable',
- name: 'Unstoppable',
- icon: '🔱',
- description: 'Reach a 30-day streak',
- streakRequired: 30
- },
- EARLY_BIRD: {
- id: 'early_bird',
- name: 'Early Bird',
- icon: '🌅',
- description: 'Complete 5 tasks before their deadline',
- earlyCompletions: 5
- },
- NIGHT_OWL: {
- id: 'night_owl',
- name: 'Night Owl',
- icon: '🦉',
- description: 'Complete 5 tasks between 10 PM and 4 AM',
- nightCompletions: 5
- },
- MULTITASKER: {
- id: 'multitasker',
- name: 'Multitasker',
- icon: '🎯',
- description: 'Complete 3 tasks in one day',
- tasksPerDay: 3
- },
- WEEKEND_WARRIOR: {
- id: 'weekend_warrior',
- name: 'Weekend Warrior',
- icon: '⚔️',
- description: 'Complete 10 tasks during weekends',
- weekendCompletions: 10
- },
- PERFECTIONIST: {
- id: 'perfectionist',
- name: 'Perfectionist',
- icon: '💎',
- description: 'Complete 10 tasks exactly on their deadline',
- exactDeadlines: 10
- },
- MASTER_OF_TIME: {
- id: 'master_of_time',
- name: 'Time Lord',
- icon: '⌛',
- description: 'Complete 25 tasks before their deadline',
- earlyCompletions: 25
- },
- NIGHT_CHAMPION: {
- id: 'night_champion',
- name: 'Night Champion',
- icon: '🌙',
- description: 'Complete 15 tasks between 10 PM and 4 AM',
- nightCompletions: 15
- },
- PRODUCTIVITY_KING: {
- id: 'productivity_king',
- name: 'Productivity King',
- icon: '👨💼',
- description: 'Complete 5 tasks in one day',
- tasksPerDay: 5
- },
- WEEKEND_MASTER: {
- id: 'weekend_master',
- name: 'Weekend Master',
- icon: '🎯',
- description: 'Complete 50 tasks during weekends',
- weekendCompletions: 50
- },
- GRANDMASTER: {
- id: 'grandmaster',
- name: 'Grandmaster',
- icon: '🎭',
- description: 'Reach level 100',
- level: 100
- },
- MARATHON_RUNNER: {
- id: 'marathon_runner',
- name: 'Marathon Runner',
- icon: '🏃',
- description: 'Reach a 100-day streak',
- streakRequired: 100
- },
- TASK_EMPEROR: {
- id: 'task_emperor',
- name: 'Task Emperor',
- icon: '👑',
- description: 'Complete 1000 tasks',
- tasksRequired: 1000
- },
- ULTIMATE_CHAMPION: {
- id: 'ultimate_champion',
- name: 'Ultimate Champion',
- icon: '🏅',
- description: 'Complete 50 tasks before their deadline',
- earlyCompletions: 50
- }
-};
-
-const BadgeGrid = ({ unlockedBadges }) => {
- const [showModal, setShowModal] = useState(false);
- const totalBadges = Object.keys(BADGES).length;
- const progress = (unlockedBadges.length / totalBadges) * 100;
-
- return (
- <>
-
-
-
-
- Badges
-
-
- {unlockedBadges.length} of {totalBadges} unlocked
-
-
-
-
-
- {/* Progress Bar */}
-
-
-
- setShowModal(false)}
- badges={BADGES}
- unlockedBadges={unlockedBadges}
- />
- >
- );
-};
-
-export default BadgeGrid;
+import React, { useState } from 'react';
+import BadgesModal from './BadgesModal';
+import { Trophy } from 'lucide-react';
+
+export const BADGES = {
+ NOVICE: {
+ id: 'novice',
+ name: 'Novice',
+ icon: '🌟',
+ description: 'Reach level 5',
+ level: 5
+ },
+ INTERMEDIATE: {
+ id: 'intermediate',
+ name: 'Intermediate',
+ icon: '⭐',
+ description: 'Reach level 10',
+ level: 10
+ },
+ XP_HUNTER: {
+ id: 'xp_hunter',
+ name: 'XP Hunter',
+ icon: '💫',
+ description: 'Reach level 15',
+ level: 15
+ },
+ STREAK_MASTER: {
+ id: 'streak_master',
+ name: 'Consistent',
+ icon: '🔥',
+ description: 'Reach a 5-day streak',
+ streakRequired: 5
+ },
+ TASK_ACHIEVER: {
+ id: 'task_achiever',
+ name: 'Achiever',
+ icon: '✅',
+ description: 'Complete 20 tasks',
+ tasksRequired: 20
+ },
+ TASK_MASTER: {
+ id: 'task_master',
+ name: 'Task Master',
+ icon: '👑',
+ description: 'Complete 50 tasks',
+ tasksRequired: 50
+ },
+ DEDICATION: {
+ id: 'dedication',
+ name: 'Dedication',
+ icon: '💪',
+ description: 'Reach a 10-day streak',
+ streakRequired: 10
+ },
+ ELITE: {
+ id: 'elite',
+ name: 'Elite',
+ icon: '🏆',
+ description: 'Reach level 25',
+ level: 25
+ },
+ LEGENDARY: {
+ id: 'legendary',
+ name: 'Legendary',
+ icon: '⚡',
+ description: 'Complete 100 tasks',
+ tasksRequired: 100
+ },
+ UNSTOPPABLE: {
+ id: 'unstoppable',
+ name: 'Unstoppable',
+ icon: '🔱',
+ description: 'Reach a 30-day streak',
+ streakRequired: 30
+ },
+ EARLY_BIRD: {
+ id: 'early_bird',
+ name: 'Early Bird',
+ icon: '🌅',
+ description: 'Complete 5 tasks before their deadline',
+ earlyCompletions: 5
+ },
+ NIGHT_OWL: {
+ id: 'night_owl',
+ name: 'Night Owl',
+ icon: '🦉',
+ description: 'Complete 5 tasks between 10 PM and 4 AM',
+ nightCompletions: 5
+ },
+ MULTITASKER: {
+ id: 'multitasker',
+ name: 'Multitasker',
+ icon: '🎯',
+ description: 'Complete 3 tasks in one day',
+ tasksPerDay: 3
+ },
+ WEEKEND_WARRIOR: {
+ id: 'weekend_warrior',
+ name: 'Weekend Warrior',
+ icon: '⚔️',
+ description: 'Complete 10 tasks during weekends',
+ weekendCompletions: 10
+ },
+ PERFECTIONIST: {
+ id: 'perfectionist',
+ name: 'Perfectionist',
+ icon: '💎',
+ description: 'Complete 10 tasks exactly on their deadline',
+ exactDeadlines: 10
+ },
+ MASTER_OF_TIME: {
+ id: 'master_of_time',
+ name: 'Time Lord',
+ icon: '⌛',
+ description: 'Complete 25 tasks before their deadline',
+ earlyCompletions: 25
+ },
+ NIGHT_CHAMPION: {
+ id: 'night_champion',
+ name: 'Night Champion',
+ icon: '🌙',
+ description: 'Complete 15 tasks between 10 PM and 4 AM',
+ nightCompletions: 15
+ },
+ PRODUCTIVITY_KING: {
+ id: 'productivity_king',
+ name: 'Productivity King',
+ icon: '👨💼',
+ description: 'Complete 5 tasks in one day',
+ tasksPerDay: 5
+ },
+ WEEKEND_MASTER: {
+ id: 'weekend_master',
+ name: 'Weekend Master',
+ icon: '🎯',
+ description: 'Complete 50 tasks during weekends',
+ weekendCompletions: 50
+ },
+ GRANDMASTER: {
+ id: 'grandmaster',
+ name: 'Grandmaster',
+ icon: '🎭',
+ description: 'Reach level 100',
+ level: 100
+ },
+ MARATHON_RUNNER: {
+ id: 'marathon_runner',
+ name: 'Marathon Runner',
+ icon: '🏃',
+ description: 'Reach a 100-day streak',
+ streakRequired: 100
+ },
+ TASK_EMPEROR: {
+ id: 'task_emperor',
+ name: 'Task Emperor',
+ icon: '👑',
+ description: 'Complete 1000 tasks',
+ tasksRequired: 1000
+ },
+ ULTIMATE_CHAMPION: {
+ id: 'ultimate_champion',
+ name: 'Ultimate Champion',
+ icon: '🏅',
+ description: 'Complete 50 tasks before their deadline',
+ earlyCompletions: 50
+ }
+};
+
+const BadgeGrid = ({ unlockedBadges }) => {
+ const [showModal, setShowModal] = useState(false);
+ const totalBadges = Object.keys(BADGES).length;
+ const progress = (unlockedBadges.length / totalBadges) * 100;
+
+ return (
+ <>
+
+
+
+
+ Badges
+
+
+ {unlockedBadges.length} of {totalBadges} unlocked
+
+
+
+
+
+ {/* Progress Bar */}
+
+
+
+ setShowModal(false)}
+ badges={BADGES}
+ unlockedBadges={unlockedBadges}
+ />
+ >
+ );
+};
+
+export default BadgeGrid;
diff --git a/client/src/services/badge/BadgeManager.js b/client/src/services/badge/BadgeManager.js
index 3de1848..018d1f8 100644
--- a/client/src/services/badge/BadgeManager.js
+++ b/client/src/services/badge/BadgeManager.js
@@ -1,56 +1,69 @@
-import { checkBadgeUnlocks } from '../../utils/badges/badgeUtils';
-import { BADGES } from '../../components/Badge/BadgeGrid';
-
-class BadgeManager {
- constructor(setUnlockedBadges, addNotification) {
- this.setUnlockedBadges = setUnlockedBadges;
- this.addNotification = addNotification;
- this.notifiedBadges = new Set(
- JSON.parse(localStorage.getItem('notifiedBadges') || '[]')
- );
- }
-
- notifyNewBadge(badgeId) {
- if (this.notifiedBadges.has(badgeId)) return;
-
- const badge = BADGES[badgeId.toUpperCase()];
- if (badge && this.addNotification) {
- this.addNotification(
- `🏆 New Badge Unlocked: ${badge.icon} ${badge.name}!`,
- 'achievement',
- `badge_${badgeId}`
- );
- this.notifiedBadges.add(badgeId);
- localStorage.setItem(
- 'notifiedBadges',
- JSON.stringify([...this.notifiedBadges])
- );
- }
- }
-
- checkAndUpdateBadges(
- level,
- currentStreak,
- completedTasks,
- currentUnlockedBadges = []
- ) {
- const tasksLength = Array.isArray(completedTasks)
- ? completedTasks.length
- : 0;
-
- const newUnlockedBadges =
- checkBadgeUnlocks(level, currentStreak, tasksLength, completedTasks) ||
- [];
-
- newUnlockedBadges.forEach(this.notifyNewBadge.bind(this));
-
- if (
- JSON.stringify(newUnlockedBadges) !==
- JSON.stringify(currentUnlockedBadges)
- ) {
- this.setUnlockedBadges(newUnlockedBadges);
- }
- }
-}
-
-export default BadgeManager;
+import { checkBadgeUnlocks } from '../../utils/badges/badgeUtils';
+import { BADGES } from '../../components/Badge/BadgeGrid';
+
+class BadgeManager {
+ constructor(setUnlockedBadges, addNotification) {
+ this.setUnlockedBadges = setUnlockedBadges;
+ this.addNotification = addNotification;
+ this.notifiedBadges = new Set(
+ JSON.parse(localStorage.getItem('notifiedBadges') || '[]')
+ );
+
+ this.notifyNewBadge = this.notifyNewBadge.bind(this);
+ }
+
+ notifyNewBadge(badgeId) {
+ if (this.notifiedBadges.has(badgeId)) return;
+
+ const badge = BADGES[badgeId.toUpperCase()];
+ if (badge && this.addNotification) {
+ this.addNotification(
+ `🏆 New Badge Unlocked: ${badge.icon} ${badge.name}!`,
+ 'achievement',
+ `badge_${badgeId}`
+ );
+ this.notifiedBadges.add(badgeId);
+ localStorage.setItem(
+ 'notifiedBadges',
+ JSON.stringify([...this.notifiedBadges])
+ );
+ }
+ }
+
+ checkForNewBadges(
+ level,
+ currentStreak,
+ completedTasks,
+ currentUnlockedBadges = []
+ ) {
+ const tasksLength = Array.isArray(completedTasks) ? completedTasks.length : 0;
+
+ const newlyUnlockedBadges = checkBadgeUnlocks(
+ level,
+ currentStreak,
+ tasksLength,
+ completedTasks
+ ) || [];
+
+ const uniqueNewBadges = newlyUnlockedBadges.filter(
+ (badge) => !currentUnlockedBadges.includes(badge)
+ );
+
+ if (uniqueNewBadges.length > 0) {
+ uniqueNewBadges.forEach(this.notifyNewBadge);
+
+ const updatedBadges = [...new Set([...currentUnlockedBadges, ...uniqueNewBadges])];
+ this.setUnlockedBadges(updatedBadges);
+ return updatedBadges;
+ }
+
+ return currentUnlockedBadges;
+ }
+
+ clearNotificationHistory() {
+ this.notifiedBadges.clear();
+ localStorage.removeItem('notifiedBadges');
+ }
+}
+
+export default BadgeManager;
diff --git a/client/src/services/badge/__tests__/BadgeManager.test.js b/client/src/services/badge/__tests__/BadgeManager.test.js
index d59a419..09cf577 100644
--- a/client/src/services/badge/__tests__/BadgeManager.test.js
+++ b/client/src/services/badge/__tests__/BadgeManager.test.js
@@ -6,14 +6,16 @@ jest.mock('../../../utils/badges/badgeUtils');
describe('BadgeManager', () => {
let badgeManager;
let mockSetUnlockedBadges;
+ let mockAddNotification;
beforeEach(() => {
mockSetUnlockedBadges = jest.fn();
- badgeManager = new BadgeManager(mockSetUnlockedBadges);
+ mockAddNotification = jest.fn();
+ badgeManager = new BadgeManager(mockSetUnlockedBadges, mockAddNotification);
checkBadgeUnlocks.mockReset();
});
- describe('checkAndUpdateBadges', () => {
+ describe('checkForNewBadges', () => {
it('should update badges when new badges are unlocked', () => {
const currentUnlockedBadges = ['badge1'];
const newUnlockedBadges = ['badge1', 'badge2'];
@@ -21,18 +23,18 @@ describe('BadgeManager', () => {
checkBadgeUnlocks.mockReturnValue(newUnlockedBadges);
- badgeManager.checkAndUpdateBadges(
- 5, // level
- 3, // currentStreak
- completedTasks, // completedTasks array
- currentUnlockedBadges // current badges
+ badgeManager.checkForNewBadges(
+ 5,
+ 3,
+ completedTasks,
+ currentUnlockedBadges
);
expect(checkBadgeUnlocks).toHaveBeenCalledWith(
- 5, // level
- 3, // streak
- 10, // tasks length
- completedTasks // tasks array
+ 5,
+ 3,
+ 10,
+ completedTasks
);
expect(mockSetUnlockedBadges).toHaveBeenCalledWith(newUnlockedBadges);
});
@@ -42,7 +44,7 @@ describe('BadgeManager', () => {
checkBadgeUnlocks.mockReturnValue(['badge1']);
- badgeManager.checkAndUpdateBadges(
+ badgeManager.checkForNewBadges(
5,
3,
Array(10).fill({ id: 'task' }),
@@ -57,13 +59,13 @@ describe('BadgeManager', () => {
const emptyTasks = [];
checkBadgeUnlocks.mockReturnValue([]);
- badgeManager.checkAndUpdateBadges(1, 0, emptyTasks, []);
+ badgeManager.checkForNewBadges(1, 0, emptyTasks, []);
expect(checkBadgeUnlocks).toHaveBeenCalledWith(
- 1, // level
- 0, // streak
- 0, // tasks length
- emptyTasks // tasks array
+ 1,
+ 0,
+ 0,
+ emptyTasks
);
expect(mockSetUnlockedBadges).not.toHaveBeenCalled();
});
@@ -71,17 +73,17 @@ describe('BadgeManager', () => {
it('should pass correct parameters to checkBadgeUnlocks', () => {
const completedTasks = Array(5).fill({ id: 'task' });
- badgeManager.checkAndUpdateBadges(
- 10, // level
- 7, // streak
+ badgeManager.checkForNewBadges(
+ 10,
+ 7,
completedTasks,
- [] // current badges
+ []
);
expect(checkBadgeUnlocks).toHaveBeenCalledWith(
- 10, // level
- 7, // streak
- 5, // completed tasks count
+ 10,
+ 7,
+ 5,
completedTasks
);
});
@@ -90,7 +92,7 @@ describe('BadgeManager', () => {
const newUnlockedBadges = ['badge1'];
checkBadgeUnlocks.mockReturnValue(newUnlockedBadges);
- badgeManager.checkAndUpdateBadges(1, 0, [], undefined);
+ badgeManager.checkForNewBadges(1, 0, [], undefined);
expect(checkBadgeUnlocks).toHaveBeenCalled();
expect(mockSetUnlockedBadges).toHaveBeenCalledWith(newUnlockedBadges);
@@ -118,7 +120,7 @@ describe('BadgeManager', () => {
scenarios.forEach((scenario) => {
checkBadgeUnlocks.mockReturnValue(scenario.new);
- badgeManager.checkAndUpdateBadges(10, 5, [], scenario.current);
+ badgeManager.checkForNewBadges(10, 5, [], scenario.current);
if (scenario.shouldUpdate) {
expect(mockSetUnlockedBadges).toHaveBeenCalledWith(scenario.new);
diff --git a/client/src/services/task/TaskManager.js b/client/src/services/task/TaskManager.js
index 8b359b5..cfca3ff 100644
--- a/client/src/services/task/TaskManager.js
+++ b/client/src/services/task/TaskManager.js
@@ -1,236 +1,248 @@
-import { v4 as uuidv4 } from 'uuid';
-import { startConfetti } from '../../utils/other/confettiUtils';
-
-class TaskManager {
- constructor(calculateXP, setTasks, setCompletedTasks, setError) {
- this.calculateXP = calculateXP;
- this.setTasks = setTasks;
- this.setCompletedTasks = setCompletedTasks;
- this.setError = setError;
- }
-
- handleError(error, message) {
- console.error(message, error);
- this.setError(error.message);
- }
-
- addTask = async (taskData) => {
- try {
- const tasksToAdd = Array.isArray(taskData) ? taskData : [taskData];
-
- const newTasks = tasksToAdd.map((task) => ({
- ...task,
- id: uuidv4(),
- createdAt: new Date().toISOString(),
- label: task.label || null
- }));
-
- this.setTasks((currentTasks) => [...currentTasks, ...newTasks]);
- } catch (error) {
- this.handleError(error, 'Error adding task:');
- }
- };
-
- completeTask = async (task) => {
- try {
- startConfetti();
- this.setTasks((currentTasks) =>
- currentTasks.filter((t) => t.id !== task.id)
- );
-
- const completedTask = {
- ...task,
- completedAt: new Date().toISOString() // Store UTC timestamp
- };
-
- const xpResult = this.calculateXP(task.experience, task.deadline);
- completedTask.earlyBonus = xpResult.earlyBonus;
- completedTask.overduePenalty = xpResult.overduePenalty;
-
- this.setCompletedTasks((prev) => [...prev, completedTask]);
- } catch (error) {
- this.handleError(error, 'Error completing task:');
- }
- };
-
- removeTask = (taskId, isCompleted) => {
- try {
- if (isCompleted) {
- this.setCompletedTasks((prev) => {
- const taskToRemove = prev.find((t) => t.id === taskId);
- if (taskToRemove) {
- let totalXPToRemove = taskToRemove.experience;
- if (taskToRemove.earlyBonus)
- totalXPToRemove += taskToRemove.earlyBonus;
- if (taskToRemove.overduePenalty)
- totalXPToRemove += taskToRemove.overduePenalty;
- this.calculateXP(-totalXPToRemove);
- }
- return prev.filter((t) => t.id !== taskId);
- });
- } else {
- this.setTasks((prev) => prev.filter((t) => t.id !== taskId));
- }
- } catch (error) {
- this.handleError(error, 'Error removing task:');
- }
- };
-
- updateTask = async (taskId, updatedTask) => {
- try {
- // First check if this is a shared project update
- if (updatedTask.isShared) {
- const baseUrl = process.env.REACT_APP_PROD || 'http://localhost:3001/api';
-
- // Sync the update with server first
- const response = await fetch(
- `${baseUrl}/collaboration/projects/${taskId}/details`,
- {
- method: 'PUT',
- credentials: 'include',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(updatedTask),
- }
- );
-
- if (!response.ok) {
- const errorText = await response.text();
- console.error('[UPDATE-TASK] Server error:', errorText);
- throw new Error(`Failed to sync project update: ${response.status}`);
- }
- }
-
- // Update local state after successful sync (or if not shared)
- this.setTasks((currentTasks) =>
- currentTasks.map((task) => {
- if (task.id === taskId) {
- return { ...task, ...updatedTask };
- }
- return task;
- })
- );
- } catch (error) {
- this.handleError(error, '[UPDATE-TASK] Error updating task:');
- throw error; // Re-throw to let caller handle the error
- }
- };
-
- async joinProject(shareCode) {
- try {
- const baseUrl = process.env.REACT_APP_PROD || 'http://localhost:3001/api';
- const response = await fetch(`${baseUrl}/collaboration/projects/${shareCode}`, {
- method: 'GET',
- credentials: 'include',
- headers: {
- 'Accept': 'application/json',
- 'Content-Type': 'application/json',
- }
- });
-
- if (!response.ok) {
- return false;
- }
-
- const project = await response.json();
-
- this.setTasks(currentTasks => {
- const exists = currentTasks.some(t => t.id === project.id);
- if (!exists) {
- return [...currentTasks, project];
- }
- return currentTasks;
- });
-
- return true;
- } catch (error) {
- console.error('[JOIN] Error joining project:', error);
- return false;
- }
- }
-
- startProjectSync(taskId) {
- console.log(`[SYNC] Starting sync for project: ${taskId}`);
- let syncAttempts = 0;
- const maxAttempts = 3;
-
- const syncInterval = setInterval(async () => {
- try {
- console.log(`[SYNC] Polling updates for project ${taskId}`);
- const baseUrl = process.env.REACT_APP_PROD || 'http://localhost:3001/api';
- const url = `${baseUrl}/collaboration/projects/${taskId}`;
- console.log(`[SYNC] Making request to: ${url}`);
-
- const response = await fetch(url, {
- method: 'GET',
- credentials: 'include',
- headers: {
- 'Accept': 'application/json',
- 'Content-Type': 'application/json',
- }
- });
-
- if (!response.ok) {
- syncAttempts++;
- const errorText = await response.text();
- console.error(`[SYNC] Server error response (attempt ${syncAttempts}/${maxAttempts}):`, errorText);
-
- if (syncAttempts >= maxAttempts) {
- console.error(`[SYNC] Max retry attempts reached for project ${taskId}`);
- clearInterval(syncInterval);
- return;
- }
-
- throw new Error(`Failed to sync project: ${response.status}`);
- }
-
- // Reset attempts on successful response
- syncAttempts = 0;
-
- const rawResponse = await response.text();
- console.log('[SYNC] Raw server response:', rawResponse);
-
- let project;
- try {
- project = JSON.parse(rawResponse);
- } catch (e) {
- console.error('[SYNC] Failed to parse response as JSON:', e);
- throw new Error('Invalid response format from server');
- }
-
- console.log(`[SYNC] Received project update:`, project);
-
- // Update the task in state if it exists and has changed
- this.setTasks(currentTasks => {
- const taskIndex = currentTasks.findIndex(t => t.id === taskId);
- if (taskIndex === -1) {
- console.log(`[SYNC] Task ${taskId} not found in current tasks`);
- return currentTasks;
- }
-
- const currentTask = currentTasks[taskIndex];
- if (JSON.stringify(currentTask) === JSON.stringify(project)) {
- console.log(`[SYNC] No changes detected for project ${taskId}`);
- return currentTasks;
- }
-
- console.log(`[SYNC] Updating local state for project ${taskId}`);
- const newTasks = [...currentTasks];
- newTasks[taskIndex] = project;
- return newTasks;
- });
-
- } catch (error) {
- console.error('[SYNC] Project sync error:', error);
- }
- }, 5000);
-
- return () => {
- console.log(`[SYNC] Stopping sync for project ${taskId}`);
- clearInterval(syncInterval);
- };
- }
-}
-
-export default TaskManager;
+import { v4 as uuidv4 } from 'uuid';
+import { startConfetti } from '../../utils/other/confettiUtils';
+
+class TaskManager {
+ constructor(calculateXP, setTasks, setCompletedTasks, setError, badgeManager = null) {
+ this.calculateXP = calculateXP;
+ this.setTasks = setTasks;
+ this.setCompletedTasks = setCompletedTasks;
+ this.setError = setError;
+ this.badgeManager = badgeManager;
+ }
+
+ handleError(error, message) {
+ console.error(message, error);
+ this.setError(error.message);
+ }
+
+ addTask = async (taskData) => {
+ try {
+ const tasksToAdd = Array.isArray(taskData) ? taskData : [taskData];
+
+ const newTasks = tasksToAdd.map((task) => ({
+ ...task,
+ id: uuidv4(),
+ createdAt: new Date().toISOString(),
+ label: task.label || null
+ }));
+
+ this.setTasks((currentTasks) => [...currentTasks, ...newTasks]);
+ } catch (error) {
+ this.handleError(error, 'Error adding task:');
+ }
+ };
+
+ completeTask = async (task) => {
+ try {
+ startConfetti();
+ this.setTasks((currentTasks) =>
+ currentTasks.filter((t) => t.id !== task.id)
+ );
+
+ const completedTask = {
+ ...task,
+ completedAt: new Date().toISOString() // Store UTC timestamp
+ };
+
+ const xpResult = this.calculateXP(task.experience, task.deadline);
+ completedTask.earlyBonus = xpResult.earlyBonus;
+ completedTask.overduePenalty = xpResult.overduePenalty;
+
+ // Update completed tasks and check for badges
+ this.setCompletedTasks((prev) => {
+ const updatedTasks = [...prev, completedTask];
+ if (this.badgeManager) {
+ this.badgeManager.checkForNewBadges(
+ undefined,
+ undefined,
+ updatedTasks
+ );
+ }
+ return updatedTasks;
+ });
+ } catch (error) {
+ this.handleError(error, 'Error completing task:');
+ }
+ };
+
+ removeTask = (taskId, isCompleted) => {
+ try {
+ if (isCompleted) {
+ this.setCompletedTasks((prev) => {
+ const taskToRemove = prev.find((t) => t.id === taskId);
+ if (taskToRemove) {
+ let totalXPToRemove = taskToRemove.experience;
+ if (taskToRemove.earlyBonus)
+ totalXPToRemove += taskToRemove.earlyBonus;
+ if (taskToRemove.overduePenalty)
+ totalXPToRemove += taskToRemove.overduePenalty;
+ this.calculateXP(-totalXPToRemove);
+ }
+ return prev.filter((t) => t.id !== taskId);
+ });
+ } else {
+ this.setTasks((prev) => prev.filter((t) => t.id !== taskId));
+ }
+ } catch (error) {
+ this.handleError(error, 'Error removing task:');
+ }
+ };
+
+ updateTask = async (taskId, updatedTask) => {
+ try {
+ // First check if this is a shared project update
+ if (updatedTask.isShared) {
+ const baseUrl = process.env.REACT_APP_PROD || 'http://localhost:3001/api';
+
+ // Sync the update with server first
+ const response = await fetch(
+ `${baseUrl}/collaboration/projects/${taskId}/details`,
+ {
+ method: 'PUT',
+ credentials: 'include',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(updatedTask),
+ }
+ );
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error('[UPDATE-TASK] Server error:', errorText);
+ throw new Error(`Failed to sync project update: ${response.status}`);
+ }
+ }
+
+ // Update local state after successful sync (or if not shared)
+ this.setTasks((currentTasks) =>
+ currentTasks.map((task) => {
+ if (task.id === taskId) {
+ return { ...task, ...updatedTask };
+ }
+ return task;
+ })
+ );
+ } catch (error) {
+ this.handleError(error, '[UPDATE-TASK] Error updating task:');
+ throw error; // Re-throw to let caller handle the error
+ }
+ };
+
+ async joinProject(shareCode) {
+ try {
+ const baseUrl = process.env.REACT_APP_PROD || 'http://localhost:3001/api';
+ const response = await fetch(`${baseUrl}/collaboration/projects/${shareCode}`, {
+ method: 'GET',
+ credentials: 'include',
+ headers: {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ }
+ });
+
+ if (!response.ok) {
+ return false;
+ }
+
+ const project = await response.json();
+
+ this.setTasks(currentTasks => {
+ const exists = currentTasks.some(t => t.id === project.id);
+ if (!exists) {
+ return [...currentTasks, project];
+ }
+ return currentTasks;
+ });
+
+ return true;
+ } catch (error) {
+ console.error('[JOIN] Error joining project:', error);
+ return false;
+ }
+ }
+
+ startProjectSync(taskId) {
+ console.log(`[SYNC] Starting sync for project: ${taskId}`);
+ let syncAttempts = 0;
+ const maxAttempts = 3;
+
+ const syncInterval = setInterval(async () => {
+ try {
+ console.log(`[SYNC] Polling updates for project ${taskId}`);
+ const baseUrl = process.env.REACT_APP_PROD || 'http://localhost:3001/api';
+ const url = `${baseUrl}/collaboration/projects/${taskId}`;
+ console.log(`[SYNC] Making request to: ${url}`);
+
+ const response = await fetch(url, {
+ method: 'GET',
+ credentials: 'include',
+ headers: {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ }
+ });
+
+ if (!response.ok) {
+ syncAttempts++;
+ const errorText = await response.text();
+ console.error(`[SYNC] Server error response (attempt ${syncAttempts}/${maxAttempts}):`, errorText);
+
+ if (syncAttempts >= maxAttempts) {
+ console.error(`[SYNC] Max retry attempts reached for project ${taskId}`);
+ clearInterval(syncInterval);
+ return;
+ }
+
+ throw new Error(`Failed to sync project: ${response.status}`);
+ }
+
+ // Reset attempts on successful response
+ syncAttempts = 0;
+
+ const rawResponse = await response.text();
+ console.log('[SYNC] Raw server response:', rawResponse);
+
+ let project;
+ try {
+ project = JSON.parse(rawResponse);
+ } catch (e) {
+ console.error('[SYNC] Failed to parse response as JSON:', e);
+ throw new Error('Invalid response format from server');
+ }
+
+ console.log(`[SYNC] Received project update:`, project);
+
+ // Update the task in state if it exists and has changed
+ this.setTasks(currentTasks => {
+ const taskIndex = currentTasks.findIndex(t => t.id === taskId);
+ if (taskIndex === -1) {
+ console.log(`[SYNC] Task ${taskId} not found in current tasks`);
+ return currentTasks;
+ }
+
+ const currentTask = currentTasks[taskIndex];
+ if (JSON.stringify(currentTask) === JSON.stringify(project)) {
+ console.log(`[SYNC] No changes detected for project ${taskId}`);
+ return currentTasks;
+ }
+
+ console.log(`[SYNC] Updating local state for project ${taskId}`);
+ const newTasks = [...currentTasks];
+ newTasks[taskIndex] = project;
+ return newTasks;
+ });
+
+ } catch (error) {
+ console.error('[SYNC] Project sync error:', error);
+ }
+ }, 5000);
+
+ return () => {
+ console.log(`[SYNC] Stopping sync for project ${taskId}`);
+ clearInterval(syncInterval);
+ };
+ }
+}
+
+export default TaskManager;
diff --git a/client/src/services/user/DataManager.js b/client/src/services/user/DataManager.js
index 82eb79c..25e4c13 100644
--- a/client/src/services/user/DataManager.js
+++ b/client/src/services/user/DataManager.js
@@ -1,151 +1,150 @@
-import { validateUserId } from '../validationservice';
-
-class DataManager {
- static API_BASE_URL =
- process.env.REACT_APP_PROD || 'http://localhost:3001/api';
-
- constructor(setters) {
- this.setters = setters;
- }
-
- handleError(error, message, setError) {
- console.error(message, error);
- setError(`${message} ${error.message}`);
- }
-
- handleAuthChange = (id) => {
- this.setters.setUserId(id);
- };
-
- handleUserDataLoad = (userData) => {
- // pull data from server
- if (!userData) return;
-
- const {
- setUserId,
- setTotalExperience,
- setTasks,
- setCompletedTasks,
- setUserName,
- setUnlockedBadges
- } = this.setters;
-
- setUserId(userData.userId);
- setTotalExperience(userData.xp || 0);
- setTasks(userData.tasks || []);
- setCompletedTasks(userData.completedTasks || []);
- setUserName(userData.name || null);
- setUnlockedBadges(userData.unlockedBadges || []);
- };
-
- async checkAuth(setLoading) {
- try {
- const response = await fetch(
- `${DataManager.API_BASE_URL}/auth/current_user`,
- {
- credentials: 'include'
- }
- );
- const data = await response.json();
- if (data && data.userId) {
- this.handleAuthChange(data.userId);
- this.handleUserDataLoad(data);
- }
- } catch (error) {
- console.error('Auth check failed:', error);
- } finally {
- setLoading(false);
- }
- }
-
- async updateUserData(userId, userData) {
- try {
- validateUserId(userId);
- const response = await fetch(
- `${DataManager.API_BASE_URL}/users/${userId}`,
- {
- method: 'PUT',
- headers: {
- 'Content-Type': 'application/json'
- },
- credentials: 'include',
- body: JSON.stringify(userData)
- }
- );
-
- if (!response.ok) {
- throw new Error(`Failed to update user data: ${response.status}`);
- }
- } catch (error) {
- this.handleError(
- error,
- 'Error updating user data:',
- this.setters.setError
- );
- }
- }
-
- async clearAllData(userId) {
- try {
- const { setTasks, setCompletedTasks, resetXP } = this.setters;
-
- setTasks([]);
- setCompletedTasks([]);
- resetXP();
-
- await this.updateUserData(userId, {
- xp: 0,
- level: 1,
- tasks: [],
- completedTasks: []
- });
- } catch (error) {
- this.handleError(error, 'Error clearing data:', this.setters.setError);
- }
- }
-
- async syncUserData(userData) {
- // send local data to server
- if (!userData.userId) return;
-
- try {
- await this.updateUserData(userData.userId, {
- xp: userData.getTotalXP(),
- level: userData.level,
- tasksCompleted: userData.completedTasks.length,
- tasks: userData.tasks,
- completedTasks: userData.completedTasks,
- unlockedBadges: userData.unlockedBadges
- });
- } catch (error) {
- this.handleError(
- error,
- 'Error syncing user data:',
- this.setters.setError
- );
- }
- }
-
- async checkAndHandleAuth(setLoading) {
- if (!setLoading) return;
- try {
- const response = await fetch(
- `${DataManager.API_BASE_URL}/auth/current_user`,
- {
- credentials: 'include'
- }
- );
- const data = await response.json();
- if (data && data.userId) {
- this.handleAuthChange(data.userId);
- this.handleUserDataLoad(data);
- }
- } catch (error) {
- console.error('Auth check failed:', error);
- } finally {
- setLoading(false);
- }
- }
-}
-
-export default DataManager;
+import { validateUserId } from '../validationservice';
+
+class DataManager {
+ static API_BASE_URL =
+ process.env.REACT_APP_PROD || 'http://localhost:3001/api';
+
+ constructor(setters) {
+ this.setters = setters;
+ }
+
+ handleError(error, message, setError) {
+ console.error(message, error);
+ setError(`${message} ${error.message}`);
+ }
+
+ handleAuthChange = (id) => {
+ this.setters.setUserId(id);
+ };
+
+ handleUserDataLoad = (userData) => {
+ if (!userData) return;
+ const {
+ setUserId,
+ setTotalExperience,
+ setTasks,
+ setCompletedTasks,
+ setUserName,
+ setUnlockedBadges
+ } = this.setters;
+
+ setUserId(userData.userId);
+ setTotalExperience(userData.xp || 0);
+ setTasks(userData.tasks || []);
+ setCompletedTasks(userData.completedTasks || []);
+ setUserName(userData.name || null);
+ if (Array.isArray(userData.unlockedBadges)) {
+ setUnlockedBadges(userData.unlockedBadges);
+ }
+ };
+
+ async checkAuth(setLoading) {
+ try {
+ const response = await fetch(
+ `${DataManager.API_BASE_URL}/auth/current_user`,
+ {
+ credentials: 'include'
+ }
+ );
+ const data = await response.json();
+ if (data && data.userId) {
+ this.handleAuthChange(data.userId);
+ this.handleUserDataLoad(data);
+ }
+ } catch (error) {
+ console.error('Auth check failed:', error);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async updateUserData(userId, userData) {
+ try {
+ validateUserId(userId);
+ const response = await fetch(
+ `${DataManager.API_BASE_URL}/users/${userId}`,
+ {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ credentials: 'include',
+ body: JSON.stringify(userData)
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error(`Failed to update user data: ${response.status}`);
+ }
+ } catch (error) {
+ this.handleError(
+ error,
+ 'Error updating user data:',
+ this.setters.setError
+ );
+ }
+ }
+
+ async clearAllData(userId) {
+ try {
+ const { setTasks, setCompletedTasks, resetXP, setUnlockedBadges } = this.setters;
+
+ setTasks([]);
+ setCompletedTasks([]);
+ resetXP();
+ setUnlockedBadges([]);
+
+ await this.updateUserData(userId, {
+ xp: 0,
+ level: 1,
+ tasks: [],
+ completedTasks: [],
+ unlockedBadges: []
+ });
+ } catch (error) {
+ this.handleError(error, 'Error clearing data:', this.setters.setError);
+ }
+ }
+
+ async syncUserData(userData) {
+ if (!userData.userId) return;
+
+ try {
+ const currentBadges = userData.unlockedBadges || [];
+
+ await this.updateUserData(userData.userId, {
+ xp: userData.getTotalXP(),
+ level: userData.level,
+ tasksCompleted: userData.completedTasks.length,
+ tasks: userData.tasks,
+ completedTasks: userData.completedTasks,
+ ...(currentBadges.length > 0 && { unlockedBadges: currentBadges })
+ });
+ } catch (error) {
+ this.handleError(error, 'Error syncing user data:', this.setters.setError);
+ }
+ }
+
+ async checkAndHandleAuth(setLoading) {
+ if (!setLoading) return;
+ try {
+ const response = await fetch(
+ `${DataManager.API_BASE_URL}/auth/current_user`,
+ {
+ credentials: 'include'
+ }
+ );
+ const data = await response.json();
+ if (data && data.userId) {
+ this.handleAuthChange(data.userId);
+ this.handleUserDataLoad(data);
+ }
+ } catch (error) {
+ console.error('Auth check failed:', error);
+ } finally {
+ setLoading(false);
+ }
+ }
+}
+
+export default DataManager;
diff --git a/client/src/services/user/__tests__/DataManager.test.js b/client/src/services/user/__tests__/DataManager.test.js
index 14e036a..a51236f 100644
--- a/client/src/services/user/__tests__/DataManager.test.js
+++ b/client/src/services/user/__tests__/DataManager.test.js
@@ -139,6 +139,8 @@ describe('DataManager', () => {
expect(mockSetters.setTasks).toHaveBeenCalledWith([]);
expect(mockSetters.setCompletedTasks).toHaveBeenCalledWith([]);
expect(mockSetters.resetXP).toHaveBeenCalled();
+ expect(mockSetters.setUnlockedBadges).toHaveBeenCalledWith([]);
+
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/users/test-123'),
expect.objectContaining({
@@ -147,7 +149,8 @@ describe('DataManager', () => {
xp: 0,
level: 1,
tasks: [],
- completedTasks: []
+ completedTasks: [],
+ unlockedBadges: []
})
})
);
diff --git a/client/src/utils/badges/badgeUtils.js b/client/src/utils/badges/badgeUtils.js
index fd604f9..149940f 100644
--- a/client/src/utils/badges/badgeUtils.js
+++ b/client/src/utils/badges/badgeUtils.js
@@ -1,109 +1,109 @@
-import { BADGES } from '../../components/Badge/BadgeGrid';
-
-/**
- * Badge Unlock Manager
- * Checks various achievement conditions and returns unlocked badge IDs
- *
- * Handles multiple badge types:
- * - Level-based badges
- * - Streak-based achievements
- * - Task completion milestones
- * - Time-sensitive completions (early/night/weekend)
- * - Daily completion challenges
- * - Precision timing achievements
- *
- * @param {number} level - Current user level
- * @param {number} streak - Current streak count
- * @param {number} completedTasksCount - Total completed tasks
- * @param {Array} completedTasks - Array of task objects with completion details
- * @returns {Array} Array of unlocked badge IDs
- */
-export const checkBadgeUnlocks = (
- level,
- streak = 0,
- completedTasksCount = 0,
- completedTasks = []
-) => {
- const unlockedBadges = [];
-
- Object.values(BADGES).forEach((badge) => {
- // Level achievements
- if (badge.level && level >= badge.level) {
- unlockedBadges.push(badge.id);
- }
-
- // Streak achievements
- if (badge.streakRequired && streak >= badge.streakRequired) {
- unlockedBadges.push(badge.id);
- }
-
- // Task count achievements
- if (badge.tasksRequired && completedTasksCount >= badge.tasksRequired) {
- unlockedBadges.push(badge.id);
- }
-
- // Early completion badges
- if (badge.earlyCompletions) {
- const earlyCount = completedTasks.filter((task) => {
- const completedDate = new Date(task.completedAt);
- const deadline = new Date(task.deadline);
- return completedDate < deadline;
- }).length;
- if (earlyCount >= badge.earlyCompletions) {
- unlockedBadges.push(badge.id);
- }
- }
-
- // Night owl badges (10 PM - 4 AM)
- if (badge.nightCompletions) {
- const nightCount = completedTasks.filter((task) => {
- const completedHour = new Date(task.completedAt).getHours();
- return completedHour >= 22 || completedHour <= 4;
- }).length;
- if (nightCount >= badge.nightCompletions) {
- unlockedBadges.push(badge.id);
- }
- }
-
- // Daily achievement badges
- if (badge.tasksPerDay) {
- const tasksPerDayMap = completedTasks.reduce((acc, task) => {
- const date = new Date(task.completedAt).toDateString();
- acc[date] = (acc[date] || 0) + 1;
- return acc;
- }, {});
- if (
- Object.values(tasksPerDayMap).some(
- (count) => count >= badge.tasksPerDay
- )
- ) {
- unlockedBadges.push(badge.id);
- }
- }
-
- // Weekend warrior badges
- if (badge.weekendCompletions) {
- const weekendCount = completedTasks.filter((task) => {
- const day = new Date(task.completedAt).getDay();
- return day === 0 || day === 6; // Sunday or Saturday
- }).length;
- if (weekendCount >= badge.weekendCompletions) {
- unlockedBadges.push(badge.id);
- }
- }
-
- // Deadline precision badges (within 1 hour)
- if (badge.exactDeadlines) {
- const exactCount = completedTasks.filter((task) => {
- const completedDate = new Date(task.completedAt);
- const deadline = new Date(task.deadline);
- return Math.abs(completedDate - deadline) < 1000 * 60 * 60;
- }).length;
- if (exactCount >= badge.exactDeadlines) {
- unlockedBadges.push(badge.id);
- }
- }
- });
-
- return [...new Set(unlockedBadges)];
-};
+import { BADGES } from '../../components/Badge/BadgeGrid';
+
+/**
+ * Badge Unlock Manager
+ * Checks various achievement conditions and returns unlocked badge IDs
+ *
+ * Handles multiple badge types:
+ * - Level-based badges
+ * - Streak-based achievements
+ * - Task completion milestones
+ * - Time-sensitive completions (early/night/weekend)
+ * - Daily completion challenges
+ * - Precision timing achievements
+ *
+ * @param {number} level - Current user level
+ * @param {number} streak - Current streak count
+ * @param {number} completedTasksCount - Total completed tasks
+ * @param {Array} completedTasks - Array of task objects with completion details
+ * @returns {Array} Array of unlocked badge IDs
+ */
+export const checkBadgeUnlocks = (
+ level,
+ streak = 0,
+ completedTasksCount = 0,
+ completedTasks = []
+) => {
+ const unlockedBadges = [];
+
+ Object.values(BADGES).forEach((badge) => {
+ // Level achievements
+ if (badge.level && level >= badge.level) {
+ unlockedBadges.push(badge.id);
+ }
+
+ // Streak achievements
+ if (badge.streakRequired && streak >= badge.streakRequired) {
+ unlockedBadges.push(badge.id);
+ }
+
+ // Task count achievements
+ if (badge.tasksRequired && completedTasksCount >= badge.tasksRequired) {
+ unlockedBadges.push(badge.id);
+ }
+
+ // Early completion badges
+ if (badge.earlyCompletions) {
+ const earlyCount = completedTasks.filter((task) => {
+ const completedDate = new Date(task.completedAt);
+ const deadline = new Date(task.deadline);
+ return completedDate < deadline;
+ }).length;
+ if (earlyCount >= badge.earlyCompletions) {
+ unlockedBadges.push(badge.id);
+ }
+ }
+
+ // Night owl badges (10 PM - 4 AM)
+ if (badge.nightCompletions) {
+ const nightCount = completedTasks.filter((task) => {
+ const completedHour = new Date(task.completedAt).getHours();
+ return completedHour >= 22 || completedHour <= 4;
+ }).length;
+ if (nightCount >= badge.nightCompletions) {
+ unlockedBadges.push(badge.id);
+ }
+ }
+
+ // Daily achievement badges
+ if (badge.tasksPerDay) {
+ const tasksPerDayMap = completedTasks.reduce((acc, task) => {
+ const date = new Date(task.completedAt).toDateString();
+ acc[date] = (acc[date] || 0) + 1;
+ return acc;
+ }, {});
+ if (
+ Object.values(tasksPerDayMap).some(
+ (count) => count >= badge.tasksPerDay
+ )
+ ) {
+ unlockedBadges.push(badge.id);
+ }
+ }
+
+ // Weekend warrior badges
+ if (badge.weekendCompletions) {
+ const weekendCount = completedTasks.filter((task) => {
+ const day = new Date(task.completedAt).getDay();
+ return day === 0 || day === 6; // Sunday or Saturday
+ }).length;
+ if (weekendCount >= badge.weekendCompletions) {
+ unlockedBadges.push(badge.id);
+ }
+ }
+
+ // Deadline precision badges (within 1 hour)
+ if (badge.exactDeadlines) {
+ const exactCount = completedTasks.filter((task) => {
+ const completedDate = new Date(task.completedAt);
+ const deadline = new Date(task.deadline);
+ return Math.abs(completedDate - deadline) < 1000 * 60 * 60;
+ }).length;
+ if (exactCount >= badge.exactDeadlines) {
+ unlockedBadges.push(badge.id);
+ }
+ }
+ });
+
+ return [...new Set(unlockedBadges)];
+};
diff --git a/server/config/passport-setup.js b/server/config/passport-setup.js
index 91db28e..a3c6b03 100644
--- a/server/config/passport-setup.js
+++ b/server/config/passport-setup.js
@@ -62,6 +62,7 @@ passport.use(
tasksCompleted: 0,
tasks: [],
completedTasks: [],
+ unlockedBadges: [],
isOptIn: false,
createdAt: new Date(),
lastLogin: new Date()
diff --git a/server/controllers/users/users.controller.js b/server/controllers/users/users.controller.js
index 4d9a11f..86397b2 100644
--- a/server/controllers/users/users.controller.js
+++ b/server/controllers/users/users.controller.js
@@ -1,143 +1,151 @@
-const { ObjectId } = require('mongodb');
-const { connectToDatabase } = require('../../db');
-
-async function updateUser(req, res) {
- if (!req.params.id || !ObjectId.isValid(req.params.id)) {
- return res.status(400).json({ error: 'Invalid user ID format' });
- }
-
- const { xp, tasksCompleted, level, tasks, completedTasks, unlockedBadges } =
- req.body;
-
- const numXP = Number(xp) || 0;
- const numTasksCompleted = Number(tasksCompleted) || 0;
- const numLevel = Number(level) || 1;
-
- const sanitizedTasks = Array.isArray(tasks)
- ? tasks.map((task) => ({
- ...task,
- deadline: task.deadline || null
- }))
- : [];
-
- const sanitizedCompletedTasks = Array.isArray(completedTasks)
- ? completedTasks.map((task) => ({
- ...task,
- deadline: task.deadline || null
- }))
- : [];
-
- if (isNaN(numXP) || isNaN(numTasksCompleted) || isNaN(numLevel)) {
- return res
- .status(400)
- .json({ error: 'Invalid xp, tasksCompleted, or level value' });
- }
-
- try {
- const db = await connectToDatabase();
- const usersCollection = db.collection('users');
-
- const userId = ObjectId.isValid(req.params.id)
- ? new ObjectId(req.params.id)
- : null;
- if (!userId) {
- return res.status(400).json({ error: 'Invalid user ID' });
- }
-
- const result = await usersCollection.updateOne(
- { _id: userId },
- {
- $set: {
- xp: numXP,
- tasksCompleted: numTasksCompleted,
- level: numLevel,
- tasks: sanitizedTasks,
- completedTasks: sanitizedCompletedTasks,
- unlockedBadges: Array.isArray(unlockedBadges) ? unlockedBadges : [],
- updatedAt: new Date()
- }
- }
- );
-
- if (result.matchedCount === 0) {
- return res.status(404).json({ error: 'User not found' });
- }
-
- res.json({ message: 'User updated successfully' });
- } catch (error) {
- console.error('Error updating user:', error);
- res.status(500).json({ error: 'Internal server error' });
- }
-}
-
-async function getUser(req, res) {
- try {
- // Validate user ID first
- if (!req.params.id || !ObjectId.isValid(req.params.id)) {
- return res.status(400).json({ error: 'Invalid user ID format' });
- }
-
- const db = await connectToDatabase();
- const usersCollection = db.collection('users');
-
- const user = await usersCollection.findOne(
- { _id: new ObjectId(req.params.id) },
- {
- projection: {
- name: 1,
- xp: 1,
- level: 1,
- isOptIn: 1,
- unlockedBadges: 1,
- _id: 1
- }
- }
- );
-
- if (!user) {
- return res.status(404).json({ error: 'User not found' });
- }
-
- console.log(`Retrieved user: ${req.params.id}`);
- res.json(user);
- } catch (error) {
- console.error('Error fetching user:', error);
- res.status(500).json({ error: 'Internal server error' });
- }
-}
-
-async function updateOptInStatus(req, res) {
- try {
- const db = await connectToDatabase();
- const usersCollection = db.collection('users');
-
- const user = await usersCollection.findOne({
- _id: new ObjectId(req.params.id)
- });
-
- if (!user) {
- return res.status(404).json({ error: 'User not found' });
- }
-
- const newStatus = !user.isOptIn;
-
- await usersCollection.updateOne(
- { _id: new ObjectId(req.params.id) },
- { $set: { isOptIn: newStatus } }
- );
-
- res.json({
- message: `Opt-in status updated successfully`,
- isOptIn: newStatus
- });
- } catch (error) {
- console.error('Error updating opt-in status:', error);
- res.status(500).json({ error: 'Internal server error' });
- }
-}
-
-module.exports = {
- updateUser,
- getUser,
- updateOptInStatus
-};
+const { ObjectId } = require('mongodb');
+const { connectToDatabase } = require('../../db');
+
+async function updateUser(req, res) {
+ if (!req.params.id || !ObjectId.isValid(req.params.id)) {
+ return res.status(400).json({ error: 'Invalid user ID format' });
+ }
+
+ const { xp, tasksCompleted, level, tasks, completedTasks, unlockedBadges } =
+ req.body;
+
+ const numXP = Number(xp) || 0;
+ const numTasksCompleted = Number(tasksCompleted) || 0;
+ const numLevel = Number(level) || 1;
+
+ const sanitizedTasks = Array.isArray(tasks)
+ ? tasks.map((task) => ({
+ ...task,
+ deadline: task.deadline || null
+ }))
+ : [];
+
+ const sanitizedCompletedTasks = Array.isArray(completedTasks)
+ ? completedTasks.map((task) => ({
+ ...task,
+ deadline: task.deadline || null
+ }))
+ : [];
+
+ if (isNaN(numXP) || isNaN(numTasksCompleted) || isNaN(numLevel)) {
+ return res
+ .status(400)
+ .json({ error: 'Invalid xp, tasksCompleted, or level value' });
+ }
+
+ try {
+ const db = await connectToDatabase();
+ const usersCollection = db.collection('users');
+
+ const userId = ObjectId.isValid(req.params.id)
+ ? new ObjectId(req.params.id)
+ : null;
+ if (!userId) {
+ return res.status(400).json({ error: 'Invalid user ID' });
+ }
+
+ // Get existing user data
+ const existingUser = await usersCollection.findOne({ _id: userId });
+ const existingBadges = existingUser?.unlockedBadges || [];
+
+ // Only merge badges if they're not being explicitly cleared
+ const mergedBadges = req.body.unlockedBadges === undefined
+ ? [...new Set([...existingBadges, ...(req.body.unlockedBadges || [])])]
+ : req.body.unlockedBadges;
+
+ const result = await usersCollection.updateOne(
+ { _id: userId },
+ {
+ $set: {
+ xp: numXP,
+ tasksCompleted: numTasksCompleted,
+ level: numLevel,
+ tasks: sanitizedTasks,
+ completedTasks: sanitizedCompletedTasks,
+ unlockedBadges: mergedBadges,
+ updatedAt: new Date()
+ }
+ }
+ );
+
+ if (result.matchedCount === 0) {
+ return res.status(404).json({ error: 'User not found' });
+ }
+
+ res.json({ message: 'User updated successfully' });
+ } catch (error) {
+ console.error('Error updating user:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+}
+
+async function getUser(req, res) {
+ try {
+ // Validate user ID first
+ if (!req.params.id || !ObjectId.isValid(req.params.id)) {
+ return res.status(400).json({ error: 'Invalid user ID format' });
+ }
+
+ const db = await connectToDatabase();
+ const usersCollection = db.collection('users');
+
+ const user = await usersCollection.findOne(
+ { _id: new ObjectId(req.params.id) },
+ {
+ projection: {
+ name: 1,
+ xp: 1,
+ level: 1,
+ isOptIn: 1,
+ unlockedBadges: 1,
+ _id: 1
+ }
+ }
+ );
+
+ if (!user) {
+ return res.status(404).json({ error: 'User not found' });
+ }
+
+ res.json(user);
+ } catch (error) {
+ console.error('Error fetching user:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+}
+
+async function updateOptInStatus(req, res) {
+ try {
+ const db = await connectToDatabase();
+ const usersCollection = db.collection('users');
+
+ const user = await usersCollection.findOne({
+ _id: new ObjectId(req.params.id)
+ });
+
+ if (!user) {
+ return res.status(404).json({ error: 'User not found' });
+ }
+
+ const newStatus = !user.isOptIn;
+
+ await usersCollection.updateOne(
+ { _id: new ObjectId(req.params.id) },
+ { $set: { isOptIn: newStatus } }
+ );
+
+ res.json({
+ message: `Opt-in status updated successfully`,
+ isOptIn: newStatus
+ });
+ } catch (error) {
+ console.error('Error updating opt-in status:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+}
+
+module.exports = {
+ updateUser,
+ getUser,
+ updateOptInStatus
+};
diff --git a/server/routes/auth/auth.routes.js b/server/routes/auth/auth.routes.js
index 8e49c88..9acbc78 100644
--- a/server/routes/auth/auth.routes.js
+++ b/server/routes/auth/auth.routes.js
@@ -62,7 +62,8 @@ router.get('/current_user', authenticateToken, (req, res) => {
level: req.user.level || 1,
tasks: req.user.tasks || [],
completedTasks: req.user.completedTasks || [],
- isOptIn: req.user.isOptIn || false
+ isOptIn: req.user.isOptIn || false,
+ unlockedBadges: req.user.unlockedBadges || []
});
});