From 66decdf3e66b7a166d099cd8be7acffeb53ff731 Mon Sep 17 00:00:00 2001 From: Satyam Pandey Date: Fri, 23 Jan 2026 17:27:35 +0530 Subject: [PATCH] feat: Add Social Expense Challenges & Gamification System #187 - Add Challenge, Achievement, UserGamification models - Implement gamification service with challenges, achievements, leaderboards - Add API routes for challenges, achievements, leaderboard, profile - Create frontend challenges page with full UI - Add challenge templates (No Spend, Coffee Savings, Meal Prep, etc.) - Implement streak tracking (login, expense tracking, budget adherence) - Add leveling system with XP progression and rank titles - Include privacy controls for leaderboard visibility - Add real-time notifications via Socket.IO --- GAMIFICATION.md | 268 ++++++ middleware/gamificationValidator.js | 185 ++++ models/Achievement.js | 91 ++ models/Challenge.js | 171 ++++ models/UserGamification.js | 267 ++++++ public/challenges.html | 1245 +++++++++++++++++++++++++++ public/gamification.js | 1171 +++++++++++++++++++++++++ routes/gamification.js | 781 +++++++++++++++++ server.js | 1 + services/gamificationService.js | 1061 +++++++++++++++++++++++ 10 files changed, 5241 insertions(+) create mode 100644 GAMIFICATION.md create mode 100644 middleware/gamificationValidator.js create mode 100644 models/Achievement.js create mode 100644 models/Challenge.js create mode 100644 models/UserGamification.js create mode 100644 public/challenges.html create mode 100644 public/gamification.js create mode 100644 routes/gamification.js create mode 100644 services/gamificationService.js diff --git a/GAMIFICATION.md b/GAMIFICATION.md new file mode 100644 index 0000000..b8b1945 --- /dev/null +++ b/GAMIFICATION.md @@ -0,0 +1,268 @@ +# Social Expense Challenges & Gamification System + +This document describes the gamification system that makes expense tracking more engaging through challenges, achievements, leaderboards, and streak tracking. + +## Overview + +The gamification system introduces game mechanics to encourage better financial habits: +- **Challenges**: Time-limited goals users can create or join +- **Achievements**: Badges earned for reaching milestones +- **Leaderboards**: Compete with friends and the community +- **Streaks**: Track consistent behavior over time + +## Features + +### 1. Financial Challenges + +#### Challenge Types +- **No Spend**: Track days with zero spending +- **Category Reduction**: Reduce spending in specific categories +- **Savings Target**: Save a specific amount +- **Budget Adherence**: Stay under budget for X days +- **Streak**: Maintain a behavior streak +- **Custom**: User-defined challenges + +#### Pre-built Challenge Templates +| Template | Type | Difficulty | Points | +|----------|------|------------|--------| +| No Spend Weekend | no_spend | Easy | 50 | +| No Spend Week | no_spend | Medium | 150 | +| Coffee Shop Savings | category_reduction | Medium | 100 | +| Meal Prep Month | category_reduction | Hard | 200 | +| Entertainment Detox | category_reduction | Medium | 120 | +| Savings Sprint | savings_target | Medium | 150 | +| Budget Warrior | budget_adherence | Hard | 250 | +| Tracking Streak | streak | Easy | 75 | + +### 2. Achievement Badges + +#### Categories +- **Tracking**: Expense tracking milestones +- **Budgeting**: Budget adherence achievements +- **Savings**: Savings-related accomplishments +- **Streaks**: Consecutive day achievements +- **Challenges**: Challenge completion badges +- **Milestones**: Goal completion achievements + +#### Achievement Tiers +- ๐Ÿฅ‰ **Bronze**: Entry-level achievements +- ๐Ÿฅˆ **Silver**: Intermediate achievements +- ๐Ÿฅ‡ **Gold**: Advanced achievements +- ๐Ÿ’Ž **Platinum**: Expert achievements +- ๐Ÿ’  **Diamond**: Master achievements + +#### Sample Achievements +| Badge | Name | Requirement | Points | +|-------|------|-------------|--------| +| ๐ŸŒฑ | First Steps | Track first expense | 10 | +| ๐Ÿ“ | Dedicated Tracker | Track 50 expenses | 25 | +| ๐Ÿ† | Tracking Master | Track 500 expenses | 100 | +| ๐Ÿ’ฐ | Budget Beginner | Stay under budget 7 days | 20 | +| ๐Ÿ‘‘ | Budget Master | Stay under budget 90 days | 150 | +| ๐Ÿ”ฅ | Week Warrior | 7-day login streak | 15 | +| ๐ŸŽฏ | Goal Crusher | Complete 5 goals | 100 | +| ๐Ÿ† | Challenge Champion | Complete 10 challenges | 150 | + +### 3. Leaderboard System + +#### Leaderboard Types +- **All Time**: Cumulative points ranking +- **Monthly**: Monthly points reset +- **Weekly**: Weekly points reset +- **Friends**: Users in shared workspaces + +#### Privacy Controls +Users can configure: +- `showOnLeaderboard`: Appear on public leaderboards +- `showAchievements`: Display earned achievements publicly +- `showChallenges`: Show challenge participation +- `showStats`: Share detailed statistics + +### 4. Streak Tracking + +#### Tracked Streaks +- **Login Streak**: Consecutive days logged in +- **Expense Tracking**: Days tracking expenses +- **Budget Adherence**: Days staying under budget +- **No Spend**: Consecutive no-spend days +- **Savings**: Days with positive savings + +### 5. Leveling System + +#### Level Progression +- Each level requires progressively more XP +- Formula: `baseXP * level * 1.5` +- Points earned = Experience gained + +#### Rank Titles +| Level Range | Rank | +|-------------|------| +| 1-4 | Novice | +| 5-9 | Apprentice | +| 10-19 | Adept | +| 20-29 | Expert | +| 30-39 | Master | +| 40-49 | Grandmaster | +| 50+ | Legend | + +## API Endpoints + +### Profile & Stats +``` +GET /api/gamification/profile - Get user's gamification profile +GET /api/gamification/stats - Get detailed stats +PATCH /api/gamification/privacy - Update privacy settings +``` + +### Achievements +``` +GET /api/gamification/achievements - Get all achievements with progress +GET /api/gamification/achievements/recent - Get recently earned achievements +``` + +### Leaderboard +``` +GET /api/gamification/leaderboard - Get global leaderboard +GET /api/gamification/leaderboard/friends - Get friends leaderboard +``` + +### Challenges +``` +POST /api/gamification/challenges - Create challenge +GET /api/gamification/challenges - Get user's challenges +GET /api/gamification/challenges/discover - Discover public challenges +GET /api/gamification/challenges/templates - Get challenge templates +GET /api/gamification/challenges/:id - Get challenge details +POST /api/gamification/challenges/:id/join - Join challenge +POST /api/gamification/challenges/:id/leave - Leave challenge +PATCH /api/gamification/challenges/:id/progress - Update progress +PUT /api/gamification/challenges/:id - Update challenge (creator) +DELETE /api/gamification/challenges/:id - Delete/cancel challenge +POST /api/gamification/challenges/:id/invite - Invite users +``` + +### Initialize +``` +POST /api/gamification/init - Initialize default achievements +``` + +## Data Models + +### Challenge Schema +```javascript +{ + title: String, + description: String, + type: 'no_spend' | 'category_reduction' | 'savings_target' | 'streak' | 'budget_adherence' | 'custom', + category: String, + targetValue: Number, + targetUnit: 'days' | 'amount' | 'percentage' | 'count', + startDate: Date, + endDate: Date, + creator: ObjectId, + isPublic: Boolean, + difficulty: 'easy' | 'medium' | 'hard' | 'extreme', + rewardPoints: Number, + participants: [{ + user: ObjectId, + progress: Number, + currentStreak: Number, + status: 'active' | 'completed' | 'failed' | 'withdrawn' + }] +} +``` + +### UserGamification Schema +```javascript +{ + user: ObjectId, + totalPoints: Number, + level: Number, + experience: Number, + rank: 'novice' | 'apprentice' | 'adept' | 'expert' | 'master' | 'grandmaster' | 'legend', + earnedAchievements: [{ achievement: ObjectId, earnedAt: Date }], + achievementProgress: [{ achievementCode: String, currentValue: Number, targetValue: Number }], + streaks: [{ type: String, currentStreak: Number, longestStreak: Number }], + privacySettings: { + showOnLeaderboard: Boolean, + showAchievements: Boolean, + showChallenges: Boolean, + showStats: Boolean + }, + stats: { + totalExpensesTracked: Number, + totalGoalsCompleted: Number, + loginStreak: Number, + // ... other stats + } +} +``` + +## Integration Points + +### Automatic Tracking +The gamification service integrates with existing features: + +1. **Expense Tracking**: Call `gamificationService.trackExpense(userId, expense)` when an expense is created +2. **Login**: Call `gamificationService.trackLogin(userId)` on successful authentication +3. **Goal Completion**: Call `gamificationService.trackGoalCompletion(userId)` when a goal is achieved +4. **Receipt Upload**: Call `gamificationService.trackReceiptUpload(userId)` on receipt upload +5. **Analytics View**: Call `gamificationService.trackAnalyticsView(userId)` when viewing dashboard + +### Example Integration +```javascript +// In expense route +router.post('/', auth, async (req, res) => { + // ... create expense + + // Track for gamification + const gamificationService = require('../services/gamificationService'); + await gamificationService.trackExpense(req.user._id, expense); +}); +``` + +## Real-time Updates + +The system emits Socket.IO events: +- `points_earned`: When user earns points +- `achievement_unlocked`: When new achievement is earned +- `challenge_completed`: When challenge is completed +- `challenge_invitation`: When invited to a challenge + +## Frontend Usage + +```javascript +// Initialize +const gamification = new GamificationManager(); +await gamification.init(); + +// Create a challenge +await gamification.createChallenge({ + title: 'My Challenge', + description: 'Challenge description', + type: 'no_spend', + targetValue: 7, + startDate: new Date().toISOString(), + endDate: new Date(Date.now() + 7*24*60*60*1000).toISOString() +}); + +// Join a challenge +await gamification.joinChallenge(challengeId); + +// Get leaderboard +await gamification.loadLeaderboard('weekly'); +``` + +## Best Practices + +1. **Initialize Achievements**: Run `POST /api/gamification/init` once to seed default achievements +2. **Periodic Resets**: Use cron jobs to reset weekly/monthly points +3. **Challenge Updates**: Regularly update challenge statuses based on dates +4. **Progress Calculation**: Recalculate challenge progress when expenses change + +## Security Considerations + +- All endpoints require authentication +- Users can only modify their own challenges or challenges they created +- Privacy settings control public visibility +- Rate limiting applied to prevent abuse diff --git a/middleware/gamificationValidator.js b/middleware/gamificationValidator.js new file mode 100644 index 0000000..fd98ef5 --- /dev/null +++ b/middleware/gamificationValidator.js @@ -0,0 +1,185 @@ +const Joi = require('joi'); + +const challengeSchema = Joi.object({ + title: Joi.string().trim().min(3).max(100).required() + .messages({ + 'string.min': 'Title must be at least 3 characters', + 'string.max': 'Title cannot exceed 100 characters' + }), + description: Joi.string().trim().min(10).max(500).required() + .messages({ + 'string.min': 'Description must be at least 10 characters', + 'string.max': 'Description cannot exceed 500 characters' + }), + type: Joi.string().valid( + 'no_spend', + 'category_reduction', + 'savings_target', + 'streak', + 'budget_adherence', + 'custom' + ).required() + .messages({ + 'any.only': 'Invalid challenge type' + }), + category: Joi.string().valid( + 'food', 'transport', 'entertainment', 'utilities', + 'healthcare', 'shopping', 'coffee', 'dining', 'other', 'all' + ).default('all'), + targetValue: Joi.number().min(1).required() + .messages({ + 'number.min': 'Target value must be at least 1' + }), + targetUnit: Joi.string().valid('days', 'amount', 'percentage', 'count').default('days'), + startDate: Joi.date().iso().min('now').required() + .messages({ + 'date.min': 'Start date must be in the future' + }), + endDate: Joi.date().iso().greater(Joi.ref('startDate')).required() + .messages({ + 'date.greater': 'End date must be after start date' + }), + isPublic: Joi.boolean().default(true), + difficulty: Joi.string().valid('easy', 'medium', 'hard', 'extreme').default('medium'), + rewardPoints: Joi.number().min(10).max(1000).default(100), + maxParticipants: Joi.number().min(0).max(1000).default(0), + rules: Joi.string().trim().max(1000).optional(), + icon: Joi.string().max(10).default('๐ŸŽฏ') +}); + +const challengeUpdateSchema = Joi.object({ + title: Joi.string().trim().min(3).max(100), + description: Joi.string().trim().min(10).max(500), + isPublic: Joi.boolean(), + rules: Joi.string().trim().max(1000), + icon: Joi.string().max(10) +}).min(1); + +const progressUpdateSchema = Joi.object({ + progress: Joi.number().min(0).max(100), + currentStreak: Joi.number().min(0), + savedAmount: Joi.number().min(0), + completedDay: Joi.boolean(), + dayValue: Joi.number() +}); + +const privacySettingsSchema = Joi.object({ + showOnLeaderboard: Joi.boolean(), + showAchievements: Joi.boolean(), + showChallenges: Joi.boolean(), + showStats: Joi.boolean() +}).min(1); + +const leaderboardQuerySchema = Joi.object({ + type: Joi.string().valid('all_time', 'weekly', 'monthly').default('all_time'), + limit: Joi.number().min(1).max(100).default(50) +}); + +const challengeFiltersSchema = Joi.object({ + type: Joi.string().valid( + 'no_spend', 'category_reduction', 'savings_target', + 'streak', 'budget_adherence', 'custom' + ), + difficulty: Joi.string().valid('easy', 'medium', 'hard', 'extreme'), + status: Joi.string().valid('upcoming', 'active', 'completed'), + limit: Joi.number().min(1).max(50).default(20) +}); + +const validateChallenge = (req, res, next) => { + const { error, value } = challengeSchema.validate(req.body, { abortEarly: false }); + + if (error) { + const errors = error.details.map(detail => ({ + field: detail.path.join('.'), + message: detail.message + })); + return res.status(400).json({ error: 'Validation failed', details: errors }); + } + + req.body = value; + next(); +}; + +const validateChallengeUpdate = (req, res, next) => { + const { error, value } = challengeUpdateSchema.validate(req.body, { abortEarly: false }); + + if (error) { + const errors = error.details.map(detail => ({ + field: detail.path.join('.'), + message: detail.message + })); + return res.status(400).json({ error: 'Validation failed', details: errors }); + } + + req.body = value; + next(); +}; + +const validateProgressUpdate = (req, res, next) => { + const { error, value } = progressUpdateSchema.validate(req.body, { abortEarly: false }); + + if (error) { + const errors = error.details.map(detail => ({ + field: detail.path.join('.'), + message: detail.message + })); + return res.status(400).json({ error: 'Validation failed', details: errors }); + } + + req.body = value; + next(); +}; + +const validatePrivacySettings = (req, res, next) => { + const { error, value } = privacySettingsSchema.validate(req.body, { abortEarly: false }); + + if (error) { + const errors = error.details.map(detail => ({ + field: detail.path.join('.'), + message: detail.message + })); + return res.status(400).json({ error: 'Validation failed', details: errors }); + } + + req.body = value; + next(); +}; + +const validateLeaderboardQuery = (req, res, next) => { + const { error, value } = leaderboardQuerySchema.validate(req.query, { abortEarly: false }); + + if (error) { + const errors = error.details.map(detail => ({ + field: detail.path.join('.'), + message: detail.message + })); + return res.status(400).json({ error: 'Validation failed', details: errors }); + } + + req.query = value; + next(); +}; + +const validateChallengeFilters = (req, res, next) => { + const { error, value } = challengeFiltersSchema.validate(req.query, { abortEarly: false }); + + if (error) { + const errors = error.details.map(detail => ({ + field: detail.path.join('.'), + message: detail.message + })); + return res.status(400).json({ error: 'Validation failed', details: errors }); + } + + req.query = value; + next(); +}; + +module.exports = { + validateChallenge, + validateChallengeUpdate, + validateProgressUpdate, + validatePrivacySettings, + validateLeaderboardQuery, + validateChallengeFilters +}; diff --git a/models/Achievement.js b/models/Achievement.js new file mode 100644 index 0000000..5c8369e --- /dev/null +++ b/models/Achievement.js @@ -0,0 +1,91 @@ +const mongoose = require('mongoose'); + +const achievementSchema = new mongoose.Schema({ + code: { + type: String, + required: true, + unique: true, + trim: true + }, + name: { + type: String, + required: true, + trim: true, + maxlength: 100 + }, + description: { + type: String, + required: true, + trim: true, + maxlength: 300 + }, + icon: { + type: String, + required: true, + default: '๐Ÿ†' + }, + category: { + type: String, + required: true, + enum: ['savings', 'budgeting', 'tracking', 'social', 'challenges', 'streaks', 'milestones', 'special'] + }, + tier: { + type: String, + enum: ['bronze', 'silver', 'gold', 'platinum', 'diamond'], + default: 'bronze' + }, + points: { + type: Number, + default: 10, + min: 0 + }, + requirement: { + type: { + type: String, + required: true, + enum: [ + 'budget_streak', // Stay under budget for X days/months + 'savings_amount', // Save total X amount + 'expense_tracking', // Track X expenses + 'goal_completion', // Complete X goals + 'challenge_wins', // Win X challenges + 'login_streak', // Login X days in a row + 'category_master', // Track X expenses in one category + 'analytics_usage', // View analytics X times + 'first_action', // First time doing something + 'social_engagement', // Invite/compete with friends + 'no_spend_days', // Have X no-spend days + 'receipt_uploads', // Upload X receipts + 'custom' + ] + }, + value: { + type: Number, + required: true + }, + category: String, // For category-specific achievements + timeframe: String // 'daily', 'weekly', 'monthly', 'yearly', 'all_time' + }, + isSecret: { + type: Boolean, + default: false + }, + isActive: { + type: Boolean, + default: true + }, + rarity: { + type: String, + enum: ['common', 'uncommon', 'rare', 'epic', 'legendary'], + default: 'common' + } +}, { + timestamps: true +}); + +// Indexes +achievementSchema.index({ code: 1 }); +achievementSchema.index({ category: 1, isActive: 1 }); +achievementSchema.index({ tier: 1 }); + +module.exports = mongoose.model('Achievement', achievementSchema); diff --git a/models/Challenge.js b/models/Challenge.js new file mode 100644 index 0000000..412a041 --- /dev/null +++ b/models/Challenge.js @@ -0,0 +1,171 @@ +const mongoose = require('mongoose'); + +const participantSchema = new mongoose.Schema({ + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true + }, + joinedAt: { + type: Date, + default: Date.now + }, + progress: { + type: Number, + default: 0, + min: 0 + }, + currentStreak: { + type: Number, + default: 0 + }, + bestStreak: { + type: Number, + default: 0 + }, + completedDays: [{ + date: Date, + value: Number + }], + status: { + type: String, + enum: ['active', 'completed', 'failed', 'withdrawn'], + default: 'active' + }, + completedAt: Date, + savedAmount: { + type: Number, + default: 0 + } +}, { _id: false }); + +const challengeSchema = new mongoose.Schema({ + title: { + type: String, + required: true, + trim: true, + maxlength: 100 + }, + description: { + type: String, + required: true, + trim: true, + maxlength: 500 + }, + type: { + type: String, + required: true, + enum: [ + 'no_spend', // No spending days challenge + 'category_reduction', // Reduce spending in a category + 'savings_target', // Save a specific amount + 'streak', // Maintain a behavior streak + 'budget_adherence', // Stay under budget + 'custom' // User-defined challenge + ] + }, + category: { + type: String, + enum: ['food', 'transport', 'entertainment', 'utilities', 'healthcare', 'shopping', 'coffee', 'dining', 'other', 'all'], + default: 'all' + }, + targetValue: { + type: Number, + required: true, + min: 0 + }, + targetUnit: { + type: String, + enum: ['days', 'amount', 'percentage', 'count'], + default: 'days' + }, + startDate: { + type: Date, + required: true + }, + endDate: { + type: Date, + required: true + }, + creator: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true + }, + isPublic: { + type: Boolean, + default: true + }, + isSystemChallenge: { + type: Boolean, + default: false + }, + difficulty: { + type: String, + enum: ['easy', 'medium', 'hard', 'extreme'], + default: 'medium' + }, + rewardPoints: { + type: Number, + default: 100 + }, + rewardBadge: { + type: String, + default: null + }, + participants: [participantSchema], + maxParticipants: { + type: Number, + default: 0 // 0 means unlimited + }, + invitedUsers: [{ + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }], + rules: { + type: String, + maxlength: 1000 + }, + icon: { + type: String, + default: '๐ŸŽฏ' + }, + status: { + type: String, + enum: ['upcoming', 'active', 'completed', 'cancelled'], + default: 'upcoming' + } +}, { + timestamps: true +}); + +// Indexes +challengeSchema.index({ creator: 1, status: 1 }); +challengeSchema.index({ isPublic: 1, status: 1, startDate: 1 }); +challengeSchema.index({ 'participants.user': 1, status: 1 }); +challengeSchema.index({ endDate: 1, status: 1 }); + +// Virtual for participant count +challengeSchema.virtual('participantCount').get(function() { + return this.participants.length; +}); + +// Check if user is participant +challengeSchema.methods.isParticipant = function(userId) { + return this.participants.some(p => p.user.toString() === userId.toString()); +}; + +// Get participant data +challengeSchema.methods.getParticipant = function(userId) { + return this.participants.find(p => p.user.toString() === userId.toString()); +}; + +// Calculate days remaining +challengeSchema.methods.getDaysRemaining = function() { + const now = new Date(); + const end = new Date(this.endDate); + const diffTime = end - now; + return Math.max(0, Math.ceil(diffTime / (1000 * 60 * 60 * 24))); +}; + +module.exports = mongoose.model('Challenge', challengeSchema); diff --git a/models/UserGamification.js b/models/UserGamification.js new file mode 100644 index 0000000..c4bab0d --- /dev/null +++ b/models/UserGamification.js @@ -0,0 +1,267 @@ +const mongoose = require('mongoose'); + +const earnedAchievementSchema = new mongoose.Schema({ + achievement: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Achievement', + required: true + }, + earnedAt: { + type: Date, + default: Date.now + }, + progress: { + type: Number, + default: 100 + } +}, { _id: false }); + +const streakSchema = new mongoose.Schema({ + type: { + type: String, + required: true, + enum: ['login', 'expense_tracking', 'budget_adherence', 'no_spend', 'savings'] + }, + currentStreak: { + type: Number, + default: 0 + }, + longestStreak: { + type: Number, + default: 0 + }, + lastUpdated: { + type: Date, + default: Date.now + }, + startDate: Date +}, { _id: false }); + +const achievementProgressSchema = new mongoose.Schema({ + achievementCode: { + type: String, + required: true + }, + currentValue: { + type: Number, + default: 0 + }, + targetValue: { + type: Number, + required: true + }, + lastUpdated: { + type: Date, + default: Date.now + } +}, { _id: false }); + +const userGamificationSchema = new mongoose.Schema({ + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + unique: true + }, + totalPoints: { + type: Number, + default: 0, + min: 0 + }, + level: { + type: Number, + default: 1, + min: 1 + }, + experience: { + type: Number, + default: 0, + min: 0 + }, + experienceToNextLevel: { + type: Number, + default: 100 + }, + earnedAchievements: [earnedAchievementSchema], + achievementProgress: [achievementProgressSchema], + streaks: [streakSchema], + challengesCompleted: { + type: Number, + default: 0 + }, + challengesJoined: { + type: Number, + default: 0 + }, + totalSavedFromChallenges: { + type: Number, + default: 0 + }, + rank: { + type: String, + enum: ['novice', 'apprentice', 'adept', 'expert', 'master', 'grandmaster', 'legend'], + default: 'novice' + }, + weeklyPoints: { + type: Number, + default: 0 + }, + monthlyPoints: { + type: Number, + default: 0 + }, + lastWeeklyReset: { + type: Date, + default: Date.now + }, + lastMonthlyReset: { + type: Date, + default: Date.now + }, + privacySettings: { + showOnLeaderboard: { + type: Boolean, + default: true + }, + showAchievements: { + type: Boolean, + default: true + }, + showChallenges: { + type: Boolean, + default: true + }, + showStats: { + type: Boolean, + default: false + } + }, + stats: { + totalNoSpendDays: { + type: Number, + default: 0 + }, + totalExpensesTracked: { + type: Number, + default: 0 + }, + totalReceiptsUploaded: { + type: Number, + default: 0 + }, + totalGoalsCompleted: { + type: Number, + default: 0 + }, + analyticsViews: { + type: Number, + default: 0 + }, + lastLoginDate: Date, + loginStreak: { + type: Number, + default: 0 + } + } +}, { + timestamps: true +}); + +// Indexes +userGamificationSchema.index({ user: 1 }); +userGamificationSchema.index({ totalPoints: -1 }); +userGamificationSchema.index({ weeklyPoints: -1 }); +userGamificationSchema.index({ monthlyPoints: -1 }); +userGamificationSchema.index({ level: -1 }); +userGamificationSchema.index({ 'privacySettings.showOnLeaderboard': 1, totalPoints: -1 }); + +// Calculate level from experience +userGamificationSchema.methods.calculateLevel = function() { + // Level formula: each level requires progressively more XP + // Level 1: 0 XP, Level 2: 100 XP, Level 3: 250 XP, etc. + let level = 1; + let totalXpNeeded = 0; + const baseXp = 100; + + while (this.experience >= totalXpNeeded + (baseXp * level * 1.5)) { + totalXpNeeded += Math.floor(baseXp * level * 1.5); + level++; + } + + this.level = level; + this.experienceToNextLevel = Math.floor(baseXp * level * 1.5) - (this.experience - totalXpNeeded); + + // Update rank based on level + if (level >= 50) this.rank = 'legend'; + else if (level >= 40) this.rank = 'grandmaster'; + else if (level >= 30) this.rank = 'master'; + else if (level >= 20) this.rank = 'expert'; + else if (level >= 10) this.rank = 'adept'; + else if (level >= 5) this.rank = 'apprentice'; + else this.rank = 'novice'; + + return this.level; +}; + +// Add points and experience +userGamificationSchema.methods.addPoints = function(points) { + this.totalPoints += points; + this.weeklyPoints += points; + this.monthlyPoints += points; + this.experience += points; + this.calculateLevel(); +}; + +// Check if user has achievement +userGamificationSchema.methods.hasAchievement = function(achievementId) { + return this.earnedAchievements.some( + ea => ea.achievement.toString() === achievementId.toString() + ); +}; + +// Get achievement progress +userGamificationSchema.methods.getProgress = function(achievementCode) { + return this.achievementProgress.find(ap => ap.achievementCode === achievementCode); +}; + +// Update streak +userGamificationSchema.methods.updateStreak = function(type, increment = true) { + let streak = this.streaks.find(s => s.type === type); + + if (!streak) { + streak = { + type, + currentStreak: 0, + longestStreak: 0, + lastUpdated: new Date(), + startDate: new Date() + }; + this.streaks.push(streak); + streak = this.streaks[this.streaks.length - 1]; + } + + const now = new Date(); + const lastUpdate = new Date(streak.lastUpdated); + const daysSinceUpdate = Math.floor((now - lastUpdate) / (1000 * 60 * 60 * 24)); + + if (increment) { + if (daysSinceUpdate <= 1) { + streak.currentStreak++; + } else { + streak.currentStreak = 1; + streak.startDate = now; + } + + if (streak.currentStreak > streak.longestStreak) { + streak.longestStreak = streak.currentStreak; + } + } else { + streak.currentStreak = 0; + streak.startDate = null; + } + + streak.lastUpdated = now; + return streak; +}; + +module.exports = mongoose.model('UserGamification', userGamificationSchema); diff --git a/public/challenges.html b/public/challenges.html new file mode 100644 index 0000000..f56246c --- /dev/null +++ b/public/challenges.html @@ -0,0 +1,1245 @@ + + + + + + Challenges & Achievements - ExpenseFlow + + + + + + + +
+
+
+

Loading gamification data...

+
+
+
+ + + + + + diff --git a/public/gamification.js b/public/gamification.js new file mode 100644 index 0000000..5677a37 --- /dev/null +++ b/public/gamification.js @@ -0,0 +1,1171 @@ +/** + * Gamification Feature Module + * Handles challenges, achievements, leaderboards, and streaks + */ + +class GamificationManager { + constructor() { + this.profile = null; + this.achievements = {}; + this.challenges = []; + this.leaderboard = []; + this.templates = []; + this.baseUrl = '/api/gamification'; + } + + /** + * Get authorization headers + */ + getHeaders() { + const token = localStorage.getItem('token'); + return { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }; + } + + /** + * Initialize gamification + */ + async init() { + try { + await Promise.all([ + this.loadProfile(), + this.loadAchievements(), + this.loadChallenges(), + this.loadTemplates() + ]); + + this.setupSocketListeners(); + this.renderDashboard(); + + return true; + } catch (error) { + console.error('Gamification init error:', error); + return false; + } + } + + /** + * Load user profile + */ + async loadProfile() { + try { + const response = await fetch(`${this.baseUrl}/profile`, { + headers: this.getHeaders() + }); + + const data = await response.json(); + + if (data.success) { + this.profile = data.data; + this.updateProfileUI(); + } + + return this.profile; + } catch (error) { + console.error('Load profile error:', error); + throw error; + } + } + + /** + * Load achievements + */ + async loadAchievements() { + try { + const response = await fetch(`${this.baseUrl}/achievements`, { + headers: this.getHeaders() + }); + + const data = await response.json(); + + if (data.success) { + this.achievements = data.data.achievements; + this.achievementSummary = data.data.summary; + this.renderAchievements(); + } + + return this.achievements; + } catch (error) { + console.error('Load achievements error:', error); + throw error; + } + } + + /** + * Load user challenges + */ + async loadChallenges(status = null) { + try { + const url = status + ? `${this.baseUrl}/challenges?status=${status}` + : `${this.baseUrl}/challenges`; + + const response = await fetch(url, { + headers: this.getHeaders() + }); + + const data = await response.json(); + + if (data.success) { + this.challenges = data.data; + this.renderChallenges(); + } + + return this.challenges; + } catch (error) { + console.error('Load challenges error:', error); + throw error; + } + } + + /** + * Load challenge templates + */ + async loadTemplates() { + try { + const response = await fetch(`${this.baseUrl}/challenges/templates`, { + headers: this.getHeaders() + }); + + const data = await response.json(); + + if (data.success) { + this.templates = data.data; + } + + return this.templates; + } catch (error) { + console.error('Load templates error:', error); + throw error; + } + } + + /** + * Load leaderboard + */ + async loadLeaderboard(type = 'all_time') { + try { + const response = await fetch(`${this.baseUrl}/leaderboard?type=${type}`, { + headers: this.getHeaders() + }); + + const data = await response.json(); + + if (data.success) { + this.leaderboard = data.data.entries; + this.renderLeaderboard(type); + } + + return this.leaderboard; + } catch (error) { + console.error('Load leaderboard error:', error); + throw error; + } + } + + /** + * Load friends leaderboard + */ + async loadFriendsLeaderboard() { + try { + const response = await fetch(`${this.baseUrl}/leaderboard/friends`, { + headers: this.getHeaders() + }); + + const data = await response.json(); + + if (data.success) { + this.renderLeaderboard('friends', data.data); + } + + return data.data; + } catch (error) { + console.error('Load friends leaderboard error:', error); + throw error; + } + } + + /** + * Discover public challenges + */ + async discoverChallenges(filters = {}) { + try { + const params = new URLSearchParams(filters).toString(); + const response = await fetch(`${this.baseUrl}/challenges/discover?${params}`, { + headers: this.getHeaders() + }); + + const data = await response.json(); + + if (data.success) { + this.renderDiscoverChallenges(data.data); + } + + return data.data; + } catch (error) { + console.error('Discover challenges error:', error); + throw error; + } + } + + /** + * Create a challenge + */ + async createChallenge(challengeData) { + try { + const response = await fetch(`${this.baseUrl}/challenges`, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(challengeData) + }); + + const data = await response.json(); + + if (data.success) { + this.showNotification('Challenge created successfully!', 'success'); + await this.loadChallenges(); + this.closeModal(); + return data.data; + } else { + this.showNotification(data.error || 'Failed to create challenge', 'error'); + return null; + } + } catch (error) { + console.error('Create challenge error:', error); + this.showNotification('Failed to create challenge', 'error'); + throw error; + } + } + + /** + * Join a challenge + */ + async joinChallenge(challengeId) { + try { + const response = await fetch(`${this.baseUrl}/challenges/${challengeId}/join`, { + method: 'POST', + headers: this.getHeaders() + }); + + const data = await response.json(); + + if (data.success) { + this.showNotification('Joined challenge successfully!', 'success'); + await this.loadChallenges(); + await this.loadProfile(); + return data.data; + } else { + this.showNotification(data.error || 'Failed to join challenge', 'error'); + return null; + } + } catch (error) { + console.error('Join challenge error:', error); + this.showNotification('Failed to join challenge', 'error'); + throw error; + } + } + + /** + * Leave a challenge + */ + async leaveChallenge(challengeId) { + try { + const response = await fetch(`${this.baseUrl}/challenges/${challengeId}/leave`, { + method: 'POST', + headers: this.getHeaders() + }); + + const data = await response.json(); + + if (data.success) { + this.showNotification('Left challenge', 'info'); + await this.loadChallenges(); + return true; + } else { + this.showNotification(data.error || 'Failed to leave challenge', 'error'); + return false; + } + } catch (error) { + console.error('Leave challenge error:', error); + throw error; + } + } + + /** + * Get challenge details + */ + async getChallengeDetails(challengeId) { + try { + const response = await fetch(`${this.baseUrl}/challenges/${challengeId}`, { + headers: this.getHeaders() + }); + + const data = await response.json(); + + if (data.success) { + this.renderChallengeDetails(data.data); + return data.data; + } + + return null; + } catch (error) { + console.error('Get challenge details error:', error); + throw error; + } + } + + /** + * Update privacy settings + */ + async updatePrivacy(settings) { + try { + const response = await fetch(`${this.baseUrl}/privacy`, { + method: 'PATCH', + headers: this.getHeaders(), + body: JSON.stringify(settings) + }); + + const data = await response.json(); + + if (data.success) { + this.showNotification('Privacy settings updated', 'success'); + this.profile.privacySettings = data.data; + } + + return data.success; + } catch (error) { + console.error('Update privacy error:', error); + throw error; + } + } + + /** + * Setup socket listeners for real-time updates + */ + setupSocketListeners() { + if (typeof io !== 'undefined' && window.socket) { + window.socket.on('points_earned', (data) => { + this.showPointsAnimation(data.points, data.reason); + this.loadProfile(); + }); + + window.socket.on('achievement_unlocked', (data) => { + this.showAchievementUnlocked(data); + this.loadAchievements(); + }); + + window.socket.on('challenge_completed', (data) => { + this.showChallengeCompleted(data); + this.loadChallenges(); + this.loadProfile(); + }); + + window.socket.on('challenge_invitation', (data) => { + this.showChallengeInvitation(data); + }); + } + } + + // ==================== UI RENDERING ==================== + + /** + * Update profile UI + */ + updateProfileUI() { + if (!this.profile) return; + + const levelEl = document.getElementById('gamification-level'); + const pointsEl = document.getElementById('gamification-points'); + const rankEl = document.getElementById('gamification-rank'); + const xpBarEl = document.getElementById('gamification-xp-bar'); + + if (levelEl) levelEl.textContent = this.profile.level; + if (pointsEl) pointsEl.textContent = this.formatNumber(this.profile.totalPoints); + if (rankEl) rankEl.textContent = this.capitalizeFirst(this.profile.rank); + + if (xpBarEl) { + const xpPercent = ((this.profile.experience) / + (this.profile.experience + this.profile.experienceToNextLevel)) * 100; + xpBarEl.style.width = `${xpPercent}%`; + } + + // Update streaks display + this.renderStreaks(); + } + + /** + * Render main dashboard + */ + renderDashboard() { + const container = document.getElementById('gamification-dashboard'); + if (!container) return; + + container.innerHTML = ` +
+
+
+ ${this.profile?.level || 1} + Level +
+
+

${this.capitalizeFirst(this.profile?.rank || 'novice')}

+
+
+
+
+ ${this.profile?.experience || 0} / ${(this.profile?.experience || 0) + (this.profile?.experienceToNextLevel || 100)} XP +
+
+
+ ${this.formatNumber(this.profile?.totalPoints || 0)} + Points +
+
+
+ +
+ + + + +
+ +
+
+
+

Your Challenges

+
+ + +
+
+
+
+ +
+
+ ${this.achievementSummary?.earned || 0} / + ${this.achievementSummary?.total || 0} Earned +
+
+
+ +
+
+ + + + +
+
+
+ +
+
+
+
+ `; + + this.setupTabListeners(); + this.updateProfileUI(); + this.renderChallenges(); + this.renderAchievements(); + } + + /** + * Setup tab listeners + */ + setupTabListeners() { + const tabs = document.querySelectorAll('.tab-btn'); + + tabs.forEach(tab => { + tab.addEventListener('click', () => { + tabs.forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + + document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); + document.getElementById(`${tab.dataset.tab}-tab`).classList.add('active'); + + // Load data for tab + switch (tab.dataset.tab) { + case 'leaderboard': + this.loadLeaderboard(); + break; + case 'achievements': + this.loadAchievements(); + break; + } + }); + }); + + // Leaderboard filter listeners + document.querySelectorAll('.filter-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + + if (btn.dataset.type === 'friends') { + this.loadFriendsLeaderboard(); + } else { + this.loadLeaderboard(btn.dataset.type); + } + }); + }); + } + + /** + * Render challenges list + */ + renderChallenges() { + const container = document.getElementById('challenges-list'); + if (!container) return; + + if (!this.challenges.length) { + container.innerHTML = ` +
+ ๐ŸŽฏ +

No challenges yet

+

Create a challenge or discover public challenges to get started!

+ +
+ `; + return; + } + + container.innerHTML = this.challenges.map(challenge => ` +
+
+ ${challenge.icon} + ${challenge.difficulty} +
+

${challenge.title}

+

${challenge.description}

+
+
+
+
+ ${challenge.userProgress}% +
+ +
+ `).join(''); + } + + /** + * Render discover challenges + */ + renderDiscoverChallenges(challenges) { + const modal = document.createElement('div'); + modal.className = 'modal-overlay'; + modal.id = 'discover-modal'; + + modal.innerHTML = ` + + `; + + document.body.appendChild(modal); + } + + /** + * Render achievements + */ + renderAchievements() { + const container = document.getElementById('achievements-list'); + if (!container) return; + + const categories = Object.keys(this.achievements); + + if (!categories.length) { + container.innerHTML = '

Loading achievements...

'; + return; + } + + container.innerHTML = categories.map(category => ` +
+

${this.capitalizeFirst(category)}

+
+ ${this.achievements[category].map(a => ` +
+
${a.icon}
+
+
${a.name}
+

${a.description}

+ ${!a.isEarned && !a.isSecret ? ` +
+
+
+
+ ${a.currentValue}/${a.targetValue} +
+ ` : ''} + ${a.isEarned ? `Earned ${this.formatDate(a.earnedAt)}` : ''} +
+
+${a.points || 0}
+
+ `).join('')} +
+
+ `).join(''); + } + + /** + * Render leaderboard + */ + renderLeaderboard(type, data = null) { + const container = document.getElementById('leaderboard-list'); + if (!container) return; + + const entries = data || this.leaderboard; + + if (!entries.length) { + container.innerHTML = '

No leaderboard data available

'; + return; + } + + container.innerHTML = ` +
+ ${entries.map((entry, index) => ` +
+ + ${index < 3 ? ['๐Ÿฅ‡', '๐Ÿฅˆ', '๐Ÿฅ‰'][index] : entry.rank} + + +
+ Lv. ${entry.level} + ${this.formatNumber(entry.points)} pts +
+
+ `).join('')} +
+ `; + } + + /** + * Render streaks + */ + renderStreaks() { + const container = document.getElementById('streaks-list'); + if (!container || !this.profile) return; + + const streakTypes = [ + { type: 'login', icon: '๐Ÿ“…', name: 'Login Streak', desc: 'Days logged in consecutively' }, + { type: 'expense_tracking', icon: '๐Ÿ“', name: 'Tracking Streak', desc: 'Days tracking expenses' }, + { type: 'budget_adherence', icon: '๐Ÿ’ฐ', name: 'Budget Streak', desc: 'Days staying under budget' }, + { type: 'no_spend', icon: '๐ŸŒฑ', name: 'No Spend Streak', desc: 'Consecutive no-spend days' }, + { type: 'savings', icon: '๐Ÿ’Ž', name: 'Savings Streak', desc: 'Days saving money' } + ]; + + container.innerHTML = ` +
+ ${streakTypes.map(st => { + const streak = this.profile.streaks?.find(s => s.type === st.type); + return ` +
+
${st.icon}
+
+

${st.name}

+

${st.desc}

+
+
+
+ ${streak?.currentStreak || 0} + Current +
+
+ ${streak?.longestStreak || 0} + Best +
+
+ ${streak?.currentStreak > 0 ? '๐Ÿ”ฅ' : ''} +
+ `; + }).join('')} +
+ `; + } + + /** + * Render challenge details modal + */ + renderChallengeDetails(challenge) { + const modal = document.createElement('div'); + modal.className = 'modal-overlay'; + modal.id = 'challenge-details-modal'; + + modal.innerHTML = ` + + `; + + document.body.appendChild(modal); + } + + /** + * Show create challenge modal + */ + showCreateChallengeModal() { + const modal = document.createElement('div'); + modal.className = 'modal-overlay'; + modal.id = 'create-challenge-modal'; + + modal.innerHTML = ` + + `; + + document.body.appendChild(modal); + + // Set min date to today + const today = new Date().toISOString().split('T')[0]; + document.getElementById('challenge-start').min = today; + document.getElementById('challenge-start').value = today; + + const nextWeek = new Date(); + nextWeek.setDate(nextWeek.getDate() + 7); + document.getElementById('challenge-end').min = today; + document.getElementById('challenge-end').value = nextWeek.toISOString().split('T')[0]; + } + + /** + * Use template to fill form + */ + useTemplate(templateTitle) { + const template = this.templates.find(t => t.title === templateTitle); + if (!template) return; + + document.getElementById('challenge-title').value = template.title; + document.getElementById('challenge-desc').value = template.description; + document.getElementById('challenge-type').value = template.type; + document.getElementById('challenge-category').value = template.category || 'all'; + document.getElementById('challenge-target').value = template.targetValue; + document.getElementById('challenge-unit').value = template.targetUnit; + document.getElementById('challenge-difficulty').value = template.difficulty; + document.getElementById('challenge-points').value = template.rewardPoints; + + // Update end date based on suggested duration + if (template.suggestedDuration) { + const startDate = new Date(document.getElementById('challenge-start').value); + const endDate = new Date(startDate); + endDate.setDate(endDate.getDate() + template.suggestedDuration); + document.getElementById('challenge-end').value = endDate.toISOString().split('T')[0]; + } + } + + /** + * Handle create challenge form submission + */ + async handleCreateChallenge(event) { + event.preventDefault(); + + const challengeData = { + title: document.getElementById('challenge-title').value, + description: document.getElementById('challenge-desc').value, + type: document.getElementById('challenge-type').value, + category: document.getElementById('challenge-category').value, + targetValue: parseInt(document.getElementById('challenge-target').value), + targetUnit: document.getElementById('challenge-unit').value, + startDate: new Date(document.getElementById('challenge-start').value).toISOString(), + endDate: new Date(document.getElementById('challenge-end').value).toISOString(), + difficulty: document.getElementById('challenge-difficulty').value, + rewardPoints: parseInt(document.getElementById('challenge-points').value), + isPublic: document.getElementById('challenge-public').checked + }; + + await this.createChallenge(challengeData); + } + + /** + * Close modal + */ + closeModal() { + const modals = document.querySelectorAll('.modal-overlay'); + modals.forEach(m => m.remove()); + } + + // ==================== ANIMATIONS & NOTIFICATIONS ==================== + + /** + * Show points animation + */ + showPointsAnimation(points, reason) { + const animation = document.createElement('div'); + animation.className = 'points-animation'; + animation.innerHTML = `+${points} pts`; + animation.style.cssText = ` + position: fixed; + top: 20%; + left: 50%; + transform: translateX(-50%); + font-size: 2rem; + font-weight: bold; + color: #FFD700; + text-shadow: 2px 2px 4px rgba(0,0,0,0.5); + animation: floatUp 2s ease-out forwards; + z-index: 10000; + `; + + document.body.appendChild(animation); + + setTimeout(() => animation.remove(), 2000); + } + + /** + * Show achievement unlocked + */ + showAchievementUnlocked(achievement) { + const popup = document.createElement('div'); + popup.className = 'achievement-popup'; + popup.innerHTML = ` +
+ ${achievement.icon} +
+ Achievement Unlocked! + ${achievement.name} + +${achievement.points} points +
+
+ `; + + document.body.appendChild(popup); + + setTimeout(() => { + popup.classList.add('fade-out'); + setTimeout(() => popup.remove(), 500); + }, 4000); + } + + /** + * Show challenge completed + */ + showChallengeCompleted(data) { + const popup = document.createElement('div'); + popup.className = 'challenge-complete-popup'; + popup.innerHTML = ` +
+ ๐ŸŽ‰ +

Challenge Completed!

+

${data.challengeTitle}

+ +${data.points} points +
+ `; + + document.body.appendChild(popup); + + setTimeout(() => { + popup.classList.add('fade-out'); + setTimeout(() => popup.remove(), 500); + }, 5000); + } + + /** + * Show challenge invitation + */ + showChallengeInvitation(data) { + this.showNotification( + `${data.invitedBy} invited you to "${data.challengeTitle}"`, + 'info', + () => this.getChallengeDetails(data.challengeId) + ); + } + + /** + * Show notification + */ + showNotification(message, type = 'info', onClick = null) { + const notification = document.createElement('div'); + notification.className = `notification notification-${type}`; + notification.textContent = message; + + if (onClick) { + notification.style.cursor = 'pointer'; + notification.addEventListener('click', onClick); + } + + document.body.appendChild(notification); + + setTimeout(() => { + notification.classList.add('fade-out'); + setTimeout(() => notification.remove(), 300); + }, 3000); + } + + // ==================== UTILITY METHODS ==================== + + formatNumber(num) { + if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'; + if (num >= 1000) return (num / 1000).toFixed(1) + 'K'; + return num.toString(); + } + + formatDate(dateStr) { + const date = new Date(dateStr); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + }); + } + + capitalizeFirst(str) { + return str.charAt(0).toUpperCase() + str.slice(1); + } + + filterDiscoverChallenges() { + const type = document.getElementById('filter-type').value; + const difficulty = document.getElementById('filter-difficulty').value; + + this.discoverChallenges({ type, difficulty }); + } +} + +// Initialize +const gamification = new GamificationManager(); + +// Export for module usage +if (typeof module !== 'undefined' && module.exports) { + module.exports = { GamificationManager }; +} diff --git a/routes/gamification.js b/routes/gamification.js new file mode 100644 index 0000000..ed3ee0f --- /dev/null +++ b/routes/gamification.js @@ -0,0 +1,781 @@ +const express = require('express'); +const auth = require('../middleware/auth'); +const gamificationService = require('../services/gamificationService'); +const Challenge = require('../models/Challenge'); +const { + validateChallenge, + validateChallengeUpdate, + validateProgressUpdate, + validatePrivacySettings, + validateLeaderboardQuery, + validateChallengeFilters +} = require('../middleware/gamificationValidator'); + +const router = express.Router(); + +// ==================== PROFILE ROUTES ==================== + +/** + * @route GET /api/gamification/profile + * @desc Get user's gamification profile + * @access Private + */ +router.get('/profile', auth, async (req, res) => { + try { + const profile = await gamificationService.getOrCreateProfile(req.user._id); + + // Track login for streak + await gamificationService.trackLogin(req.user._id); + + res.json({ + success: true, + data: { + totalPoints: profile.totalPoints, + level: profile.level, + experience: profile.experience, + experienceToNextLevel: profile.experienceToNextLevel, + rank: profile.rank, + weeklyPoints: profile.weeklyPoints, + monthlyPoints: profile.monthlyPoints, + challengesCompleted: profile.challengesCompleted, + challengesJoined: profile.challengesJoined, + totalSavedFromChallenges: profile.totalSavedFromChallenges, + achievementCount: profile.earnedAchievements.length, + streaks: profile.streaks, + stats: profile.stats, + privacySettings: profile.privacySettings + } + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * @route PATCH /api/gamification/privacy + * @desc Update privacy settings + * @access Private + */ +router.patch('/privacy', auth, validatePrivacySettings, async (req, res) => { + try { + const profile = await gamificationService.getOrCreateProfile(req.user._id); + + Object.assign(profile.privacySettings, req.body); + await profile.save(); + + res.json({ + success: true, + data: profile.privacySettings + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// ==================== ACHIEVEMENTS ROUTES ==================== + +/** + * @route GET /api/gamification/achievements + * @desc Get all achievements with user progress + * @access Private + */ +router.get('/achievements', auth, async (req, res) => { + try { + const achievements = await gamificationService.getUserAchievements(req.user._id); + + // Group by category + const grouped = achievements.reduce((acc, achievement) => { + if (!acc[achievement.category]) { + acc[achievement.category] = []; + } + acc[achievement.category].push(achievement); + return acc; + }, {}); + + const summary = { + total: achievements.length, + earned: achievements.filter(a => a.isEarned).length, + inProgress: achievements.filter(a => !a.isEarned && a.progress > 0).length + }; + + res.json({ + success: true, + data: { + achievements: grouped, + summary + } + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * @route GET /api/gamification/achievements/recent + * @desc Get recently earned achievements + * @access Private + */ +router.get('/achievements/recent', auth, async (req, res) => { + try { + const profile = await gamificationService.getOrCreateProfile(req.user._id); + + const recentAchievements = await Promise.all( + profile.earnedAchievements + .sort((a, b) => new Date(b.earnedAt) - new Date(a.earnedAt)) + .slice(0, 5) + .map(async ea => { + const Achievement = require('../models/Achievement'); + const achievement = await Achievement.findById(ea.achievement); + return { + ...achievement.toObject(), + earnedAt: ea.earnedAt + }; + }) + ); + + res.json({ + success: true, + data: recentAchievements + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// ==================== LEADERBOARD ROUTES ==================== + +/** + * @route GET /api/gamification/leaderboard + * @desc Get leaderboard + * @access Private + */ +router.get('/leaderboard', auth, validateLeaderboardQuery, async (req, res) => { + try { + const { type, limit } = req.query; + const leaderboard = await gamificationService.getLeaderboard(type, limit, req.user._id); + + res.json({ + success: true, + data: { + type, + entries: leaderboard + } + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * @route GET /api/gamification/leaderboard/friends + * @desc Get leaderboard among friends (users in same workspace) + * @access Private + */ +router.get('/leaderboard/friends', auth, async (req, res) => { + try { + const Workspace = require('../models/Workspace'); + const UserGamification = require('../models/UserGamification'); + + // Find workspaces user is part of + const workspaces = await Workspace.find({ + $or: [ + { owner: req.user._id }, + { 'members.user': req.user._id } + ] + }); + + // Get all user IDs from those workspaces + const userIds = new Set([req.user._id.toString()]); + workspaces.forEach(ws => { + userIds.add(ws.owner.toString()); + ws.members.forEach(m => userIds.add(m.user.toString())); + }); + + // Get gamification profiles for these users + const profiles = await UserGamification.find({ + user: { $in: Array.from(userIds) }, + 'privacySettings.showOnLeaderboard': true + }) + .populate('user', 'name') + .sort({ totalPoints: -1 }); + + const leaderboard = profiles.map((p, index) => ({ + rank: index + 1, + userId: p.user._id, + name: p.user.name, + points: p.totalPoints, + level: p.level, + rankTitle: p.rank, + isCurrentUser: p.user._id.toString() === req.user._id.toString() + })); + + res.json({ + success: true, + data: leaderboard + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// ==================== CHALLENGE ROUTES ==================== + +/** + * @route POST /api/gamification/challenges + * @desc Create a new challenge + * @access Private + */ +router.post('/challenges', auth, validateChallenge, async (req, res) => { + try { + const challenge = await gamificationService.createChallenge(req.user._id, req.body); + + res.status(201).json({ + success: true, + data: challenge + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * @route GET /api/gamification/challenges + * @desc Get user's challenges + * @access Private + */ +router.get('/challenges', auth, async (req, res) => { + try { + const { status } = req.query; + const challenges = await gamificationService.getUserChallenges(req.user._id, status); + + res.json({ + success: true, + data: challenges + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * @route GET /api/gamification/challenges/discover + * @desc Get public challenges to join + * @access Private + */ +router.get('/challenges/discover', auth, validateChallengeFilters, async (req, res) => { + try { + const challenges = await gamificationService.getPublicChallenges(req.user._id, req.query); + + res.json({ + success: true, + data: challenges + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * @route GET /api/gamification/challenges/templates + * @desc Get challenge templates + * @access Private + */ +router.get('/challenges/templates', auth, async (req, res) => { + try { + const templates = [ + { + title: 'No Spend Weekend', + description: 'Challenge yourself to not spend anything for a weekend', + type: 'no_spend', + targetValue: 2, + targetUnit: 'days', + difficulty: 'easy', + rewardPoints: 50, + icon: '๐Ÿšซ๐Ÿ’ธ', + suggestedDuration: 2 + }, + { + title: 'No Spend Week', + description: 'Go a full week without any unnecessary spending', + type: 'no_spend', + targetValue: 7, + targetUnit: 'days', + difficulty: 'medium', + rewardPoints: 150, + icon: '๐Ÿ†', + suggestedDuration: 7 + }, + { + title: 'Coffee Shop Savings', + description: 'Reduce your coffee shop spending by 50% this month', + type: 'category_reduction', + category: 'coffee', + targetValue: 50, + targetUnit: 'percentage', + difficulty: 'medium', + rewardPoints: 100, + icon: 'โ˜•', + suggestedDuration: 30 + }, + { + title: 'Meal Prep Month', + description: 'Cut food delivery expenses by 75% this month', + type: 'category_reduction', + category: 'food', + targetValue: 75, + targetUnit: 'percentage', + difficulty: 'hard', + rewardPoints: 200, + icon: '๐Ÿณ', + suggestedDuration: 30 + }, + { + title: 'Entertainment Detox', + description: 'Reduce entertainment spending by 60% for two weeks', + type: 'category_reduction', + category: 'entertainment', + targetValue: 60, + targetUnit: 'percentage', + difficulty: 'medium', + rewardPoints: 120, + icon: '๐ŸŽฌ', + suggestedDuration: 14 + }, + { + title: 'Savings Sprint', + description: 'Save โ‚น5,000 in one month', + type: 'savings_target', + targetValue: 5000, + targetUnit: 'amount', + difficulty: 'medium', + rewardPoints: 150, + icon: '๐Ÿ’ฐ', + suggestedDuration: 30 + }, + { + title: 'Budget Warrior', + description: 'Stay under budget for 30 consecutive days', + type: 'budget_adherence', + targetValue: 30, + targetUnit: 'days', + difficulty: 'hard', + rewardPoints: 250, + icon: 'โš”๏ธ', + suggestedDuration: 30 + }, + { + title: 'Tracking Streak', + description: 'Track expenses every day for 14 days', + type: 'streak', + targetValue: 14, + targetUnit: 'days', + difficulty: 'easy', + rewardPoints: 75, + icon: '๐Ÿ“', + suggestedDuration: 14 + }, + { + title: 'Shopping Fast', + description: 'No shopping expenses for 10 days', + type: 'no_spend', + category: 'shopping', + targetValue: 10, + targetUnit: 'days', + difficulty: 'medium', + rewardPoints: 100, + icon: '๐Ÿ›๏ธ', + suggestedDuration: 10 + }, + { + title: 'Transport Saver', + description: 'Reduce transport costs by 40% this month', + type: 'category_reduction', + category: 'transport', + targetValue: 40, + targetUnit: 'percentage', + difficulty: 'medium', + rewardPoints: 100, + icon: '๐Ÿš—', + suggestedDuration: 30 + } + ]; + + res.json({ + success: true, + data: templates + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * @route GET /api/gamification/challenges/:id + * @desc Get challenge details + * @access Private + */ +router.get('/challenges/:id', auth, async (req, res) => { + try { + const challenge = await Challenge.findById(req.params.id) + .populate('creator', 'name') + .populate('participants.user', 'name'); + + if (!challenge) { + return res.status(404).json({ error: 'Challenge not found' }); + } + + // Check if user can view this challenge + if (!challenge.isPublic && !challenge.isParticipant(req.user._id) && + challenge.creator._id.toString() !== req.user._id.toString()) { + return res.status(403).json({ error: 'Access denied' }); + } + + const participant = challenge.getParticipant(req.user._id); + + // Calculate progress if user is participant + let currentProgress = null; + if (participant && challenge.status === 'active') { + currentProgress = await gamificationService.calculateChallengeProgress( + req.user._id, + challenge._id + ); + } + + res.json({ + success: true, + data: { + ...challenge.toObject(), + isParticipant: challenge.isParticipant(req.user._id), + isCreator: challenge.creator._id.toString() === req.user._id.toString(), + userProgress: participant?.progress || 0, + userStatus: participant?.status || null, + userStreak: participant?.currentStreak || 0, + userSavedAmount: participant?.savedAmount || 0, + currentProgress, + daysRemaining: challenge.getDaysRemaining(), + participantCount: challenge.participants.length, + leaderboard: challenge.participants + .filter(p => p.status !== 'withdrawn') + .sort((a, b) => b.progress - a.progress) + .slice(0, 10) + .map((p, index) => ({ + rank: index + 1, + userId: p.user._id, + name: p.user.name, + progress: p.progress, + streak: p.currentStreak + })) + } + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * @route POST /api/gamification/challenges/:id/join + * @desc Join a challenge + * @access Private + */ +router.post('/challenges/:id/join', auth, async (req, res) => { + try { + const challenge = await gamificationService.joinChallenge(req.user._id, req.params.id); + + res.json({ + success: true, + message: 'Successfully joined the challenge', + data: challenge + }); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + +/** + * @route POST /api/gamification/challenges/:id/leave + * @desc Leave a challenge + * @access Private + */ +router.post('/challenges/:id/leave', auth, async (req, res) => { + try { + const challenge = await Challenge.findById(req.params.id); + + if (!challenge) { + return res.status(404).json({ error: 'Challenge not found' }); + } + + const participant = challenge.getParticipant(req.user._id); + + if (!participant) { + return res.status(400).json({ error: 'Not a participant of this challenge' }); + } + + participant.status = 'withdrawn'; + await challenge.save(); + + res.json({ + success: true, + message: 'Successfully left the challenge' + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * @route PATCH /api/gamification/challenges/:id/progress + * @desc Update challenge progress (for custom challenges) + * @access Private + */ +router.patch('/challenges/:id/progress', auth, validateProgressUpdate, async (req, res) => { + try { + const challenge = await gamificationService.updateChallengeProgress( + req.user._id, + req.params.id, + req.body + ); + + res.json({ + success: true, + data: challenge + }); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + +/** + * @route PUT /api/gamification/challenges/:id + * @desc Update challenge (creator only) + * @access Private + */ +router.put('/challenges/:id', auth, validateChallengeUpdate, async (req, res) => { + try { + const challenge = await Challenge.findById(req.params.id); + + if (!challenge) { + return res.status(404).json({ error: 'Challenge not found' }); + } + + if (challenge.creator.toString() !== req.user._id.toString()) { + return res.status(403).json({ error: 'Only the creator can update this challenge' }); + } + + // Can only update certain fields after creation + const allowedUpdates = ['title', 'description', 'isPublic', 'rules', 'icon']; + const updates = {}; + + for (const field of allowedUpdates) { + if (req.body[field] !== undefined) { + updates[field] = req.body[field]; + } + } + + Object.assign(challenge, updates); + await challenge.save(); + + res.json({ + success: true, + data: challenge + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * @route DELETE /api/gamification/challenges/:id + * @desc Delete/cancel a challenge (creator only, before it starts) + * @access Private + */ +router.delete('/challenges/:id', auth, async (req, res) => { + try { + const challenge = await Challenge.findById(req.params.id); + + if (!challenge) { + return res.status(404).json({ error: 'Challenge not found' }); + } + + if (challenge.creator.toString() !== req.user._id.toString()) { + return res.status(403).json({ error: 'Only the creator can delete this challenge' }); + } + + if (challenge.status === 'active') { + // Mark as cancelled instead of deleting + challenge.status = 'cancelled'; + await challenge.save(); + + return res.json({ + success: true, + message: 'Challenge cancelled' + }); + } + + await Challenge.findByIdAndDelete(req.params.id); + + res.json({ + success: true, + message: 'Challenge deleted' + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * @route POST /api/gamification/challenges/:id/invite + * @desc Invite users to a challenge + * @access Private + */ +router.post('/challenges/:id/invite', auth, async (req, res) => { + try { + const { userIds } = req.body; + + if (!userIds || !Array.isArray(userIds) || userIds.length === 0) { + return res.status(400).json({ error: 'User IDs array is required' }); + } + + const challenge = await Challenge.findById(req.params.id); + + if (!challenge) { + return res.status(404).json({ error: 'Challenge not found' }); + } + + if (challenge.creator.toString() !== req.user._id.toString()) { + return res.status(403).json({ error: 'Only the creator can invite users' }); + } + + // Add invited users + const newInvites = userIds.filter(id => + !challenge.invitedUsers.includes(id) && + !challenge.isParticipant(id) + ); + + challenge.invitedUsers.push(...newInvites); + await challenge.save(); + + // Send notifications to invited users + if (global.io) { + for (const userId of newInvites) { + global.io.to(`user_${userId}`).emit('challenge_invitation', { + challengeId: challenge._id, + challengeTitle: challenge.title, + invitedBy: req.user.name + }); + } + } + + res.json({ + success: true, + message: `Invited ${newInvites.length} users`, + data: { invitedCount: newInvites.length } + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// ==================== STATS ROUTES ==================== + +/** + * @route GET /api/gamification/stats + * @desc Get detailed gamification stats + * @access Private + */ +router.get('/stats', auth, async (req, res) => { + try { + const profile = await gamificationService.getOrCreateProfile(req.user._id); + const UserGamification = require('../models/UserGamification'); + + // Get global rank + const globalRank = await UserGamification.countDocuments({ + totalPoints: { $gt: profile.totalPoints } + }) + 1; + + const totalUsers = await UserGamification.countDocuments(); + + // Get challenge stats + const challengeStats = await Challenge.aggregate([ + { + $match: { + 'participants.user': profile.user + } + }, + { + $group: { + _id: '$status', + count: { $sum: 1 } + } + } + ]); + + const challenges = { + active: 0, + completed: 0, + upcoming: 0 + }; + + challengeStats.forEach(stat => { + if (stat._id === 'active') challenges.active = stat.count; + if (stat._id === 'completed') challenges.completed = stat.count; + if (stat._id === 'upcoming') challenges.upcoming = stat.count; + }); + + res.json({ + success: true, + data: { + points: { + total: profile.totalPoints, + weekly: profile.weeklyPoints, + monthly: profile.monthlyPoints + }, + level: { + current: profile.level, + rank: profile.rank, + experience: profile.experience, + toNextLevel: profile.experienceToNextLevel + }, + ranking: { + global: globalRank, + totalUsers, + percentile: Math.round((1 - (globalRank / totalUsers)) * 100) + }, + achievements: { + earned: profile.earnedAchievements.length, + inProgress: profile.achievementProgress.filter(ap => + ap.currentValue > 0 && ap.currentValue < ap.targetValue + ).length + }, + challenges, + streaks: profile.streaks.map(s => ({ + type: s.type, + current: s.currentStreak, + longest: s.longestStreak + })), + stats: profile.stats + } + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * @route POST /api/gamification/init + * @desc Initialize default achievements (admin only) + * @access Private + */ +router.post('/init', auth, async (req, res) => { + try { + await gamificationService.initializeDefaultAchievements(); + + res.json({ + success: true, + message: 'Default achievements initialized' + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +module.exports = router; diff --git a/server.js b/server.js index 72bca85..44f69be 100644 --- a/server.js +++ b/server.js @@ -180,6 +180,7 @@ app.use('/api/groups', require('./routes/groups')); app.use('/api/splits', require('./routes/splits')); app.use('/api/workspaces', require('./routes/workspaces')); app.use('/api/investments', require('./routes/investments')); +app.use('/api/gamification', require('./routes/gamification')); // Root route to serve the UI app.get('/', (req, res) => { diff --git a/services/gamificationService.js b/services/gamificationService.js new file mode 100644 index 0000000..dab4cdc --- /dev/null +++ b/services/gamificationService.js @@ -0,0 +1,1061 @@ +const Challenge = require('../models/Challenge'); +const Achievement = require('../models/Achievement'); +const UserGamification = require('../models/UserGamification'); +const Expense = require('../models/Expense'); +const Goal = require('../models/Goal'); +const mongoose = require('mongoose'); + +class GamificationService { + /** + * Initialize or get user gamification profile + */ + async getOrCreateProfile(userId) { + let profile = await UserGamification.findOne({ user: userId }); + + if (!profile) { + profile = new UserGamification({ user: userId }); + await profile.save(); + + // Check for first-time achievements + await this.checkAchievement(userId, 'first_login'); + } + + return profile; + } + + /** + * Award points to user + */ + async awardPoints(userId, points, reason) { + const profile = await this.getOrCreateProfile(userId); + profile.addPoints(points); + await profile.save(); + + // Emit notification if socket available + if (global.io) { + global.io.to(`user_${userId}`).emit('points_earned', { + points, + reason, + totalPoints: profile.totalPoints, + level: profile.level + }); + } + + return profile; + } + + /** + * Update user streak + */ + async updateStreak(userId, type) { + const profile = await this.getOrCreateProfile(userId); + const streak = profile.updateStreak(type, true); + await profile.save(); + + // Check streak-based achievements + await this.checkStreakAchievements(userId, type, streak.currentStreak); + + return streak; + } + + /** + * Check and award achievements + */ + async checkAchievement(userId, type, value = 1, category = null) { + const profile = await this.getOrCreateProfile(userId); + + // Find matching achievements + const query = { + 'requirement.type': type, + isActive: true + }; + + if (category) { + query['requirement.category'] = category; + } + + const achievements = await Achievement.find(query); + const newlyEarned = []; + + for (const achievement of achievements) { + // Skip if already earned + if (profile.hasAchievement(achievement._id)) continue; + + // Update or create progress + let progress = profile.getProgress(achievement.code); + + if (!progress) { + profile.achievementProgress.push({ + achievementCode: achievement.code, + currentValue: 0, + targetValue: achievement.requirement.value + }); + progress = profile.achievementProgress[profile.achievementProgress.length - 1]; + } + + // Update progress + progress.currentValue = Math.max(progress.currentValue, value); + progress.lastUpdated = new Date(); + + // Check if achievement is earned + if (progress.currentValue >= achievement.requirement.value) { + profile.earnedAchievements.push({ + achievement: achievement._id, + earnedAt: new Date(), + progress: 100 + }); + + // Award points + profile.addPoints(achievement.points); + + newlyEarned.push({ + achievement, + points: achievement.points + }); + } + } + + await profile.save(); + + // Send notifications for new achievements + if (newlyEarned.length > 0 && global.io) { + for (const earned of newlyEarned) { + global.io.to(`user_${userId}`).emit('achievement_unlocked', { + name: earned.achievement.name, + description: earned.achievement.description, + icon: earned.achievement.icon, + points: earned.points, + tier: earned.achievement.tier + }); + } + } + + return newlyEarned; + } + + /** + * Check streak-based achievements + */ + async checkStreakAchievements(userId, streakType, currentStreak) { + const typeMapping = { + 'login': 'login_streak', + 'expense_tracking': 'expense_tracking', + 'budget_adherence': 'budget_streak', + 'no_spend': 'no_spend_days' + }; + + const achievementType = typeMapping[streakType]; + if (achievementType) { + await this.checkAchievement(userId, achievementType, currentStreak); + } + } + + /** + * Get leaderboard + */ + async getLeaderboard(type = 'all_time', limit = 50, userId = null) { + let sortField; + + switch (type) { + case 'weekly': + sortField = 'weeklyPoints'; + break; + case 'monthly': + sortField = 'monthlyPoints'; + break; + default: + sortField = 'totalPoints'; + } + + const leaderboard = await UserGamification.find({ + 'privacySettings.showOnLeaderboard': true + }) + .sort({ [sortField]: -1 }) + .limit(limit) + .populate('user', 'name email') + .lean(); + + // Add rank positions + const result = leaderboard.map((entry, index) => ({ + rank: index + 1, + userId: entry.user._id, + name: entry.user.name, + points: entry[sortField], + level: entry.level, + rankTitle: entry.rank, + achievementCount: entry.earnedAchievements.length + })); + + // If userId provided, add user's position if not in top + if (userId) { + const userInList = result.find(r => r.userId.toString() === userId.toString()); + + if (!userInList) { + const userProfile = await UserGamification.findOne({ user: userId }) + .populate('user', 'name'); + + if (userProfile) { + const userRank = await UserGamification.countDocuments({ + 'privacySettings.showOnLeaderboard': true, + [sortField]: { $gt: userProfile[sortField] } + }) + 1; + + result.push({ + rank: userRank, + userId: userProfile.user._id, + name: userProfile.user.name, + points: userProfile[sortField], + level: userProfile.level, + rankTitle: userProfile.rank, + achievementCount: userProfile.earnedAchievements.length, + isCurrentUser: true + }); + } + } else { + userInList.isCurrentUser = true; + } + } + + return result; + } + + /** + * Create a challenge + */ + async createChallenge(creatorId, challengeData) { + const challenge = new Challenge({ + ...challengeData, + creator: creatorId, + participants: [{ + user: creatorId, + joinedAt: new Date(), + status: 'active' + }] + }); + + // Update challenge status based on dates + const now = new Date(); + if (challenge.startDate <= now && challenge.endDate > now) { + challenge.status = 'active'; + } else if (challenge.startDate > now) { + challenge.status = 'upcoming'; + } + + await challenge.save(); + + // Award points for creating challenge + await this.awardPoints(creatorId, 10, 'Created a challenge'); + + // Update user stats + const profile = await this.getOrCreateProfile(creatorId); + profile.challengesJoined++; + await profile.save(); + + return challenge; + } + + /** + * Join a challenge + */ + async joinChallenge(userId, challengeId) { + const challenge = await Challenge.findById(challengeId); + + if (!challenge) { + throw new Error('Challenge not found'); + } + + if (challenge.status === 'completed' || challenge.status === 'cancelled') { + throw new Error('This challenge is no longer active'); + } + + if (challenge.isParticipant(userId)) { + throw new Error('Already participating in this challenge'); + } + + if (challenge.maxParticipants > 0 && challenge.participants.length >= challenge.maxParticipants) { + throw new Error('Challenge is full'); + } + + challenge.participants.push({ + user: userId, + joinedAt: new Date(), + status: 'active' + }); + + await challenge.save(); + + // Update user stats + const profile = await this.getOrCreateProfile(userId); + profile.challengesJoined++; + await profile.save(); + + // Award points for joining + await this.awardPoints(userId, 5, 'Joined a challenge'); + + return challenge; + } + + /** + * Update challenge progress for a user + */ + async updateChallengeProgress(userId, challengeId, progressData) { + const challenge = await Challenge.findById(challengeId); + + if (!challenge) { + throw new Error('Challenge not found'); + } + + const participant = challenge.getParticipant(userId); + + if (!participant) { + throw new Error('Not a participant of this challenge'); + } + + // Update progress based on challenge type + participant.progress = progressData.progress || participant.progress; + participant.currentStreak = progressData.currentStreak || participant.currentStreak; + participant.savedAmount = progressData.savedAmount || participant.savedAmount; + + if (progressData.completedDay) { + participant.completedDays.push({ + date: new Date(), + value: progressData.dayValue || 0 + }); + } + + if (participant.currentStreak > participant.bestStreak) { + participant.bestStreak = participant.currentStreak; + } + + // Check if challenge completed + if (participant.progress >= 100 && participant.status === 'active') { + participant.status = 'completed'; + participant.completedAt = new Date(); + + // Award completion points + await this.completeChallengeForUser(userId, challenge); + } + + await challenge.save(); + + return challenge; + } + + /** + * Complete challenge for user + */ + async completeChallengeForUser(userId, challenge) { + const profile = await this.getOrCreateProfile(userId); + + // Award points + await this.awardPoints(userId, challenge.rewardPoints, `Completed challenge: ${challenge.title}`); + + // Update stats + profile.challengesCompleted++; + + const participant = challenge.getParticipant(userId); + if (participant && participant.savedAmount) { + profile.totalSavedFromChallenges += participant.savedAmount; + } + + await profile.save(); + + // Check challenge-related achievements + await this.checkAchievement(userId, 'challenge_wins', profile.challengesCompleted); + + // Award badge if challenge has one + if (challenge.rewardBadge) { + // Badge logic would go here + } + + // Emit notification + if (global.io) { + global.io.to(`user_${userId}`).emit('challenge_completed', { + challengeTitle: challenge.title, + points: challenge.rewardPoints + }); + } + } + + /** + * Calculate challenge progress based on expenses + */ + async calculateChallengeProgress(userId, challengeId) { + const challenge = await Challenge.findById(challengeId); + + if (!challenge) { + throw new Error('Challenge not found'); + } + + const participant = challenge.getParticipant(userId); + + if (!participant) { + throw new Error('Not a participant'); + } + + let progress = 0; + let savedAmount = 0; + + const startDate = new Date(Math.max(challenge.startDate, participant.joinedAt)); + const endDate = new Date(Math.min(challenge.endDate, new Date())); + + switch (challenge.type) { + case 'no_spend': { + // Count days with no spending + const expenses = await Expense.aggregate([ + { + $match: { + user: new mongoose.Types.ObjectId(userId), + type: 'expense', + date: { $gte: startDate, $lte: endDate } + } + }, + { + $group: { + _id: { + $dateToString: { format: '%Y-%m-%d', date: '$date' } + }, + total: { $sum: '$amount' } + } + } + ]); + + const daysWithExpenses = new Set(expenses.map(e => e._id)); + const totalDays = Math.ceil((endDate - startDate) / (1000 * 60 * 60 * 24)) + 1; + const noSpendDays = totalDays - daysWithExpenses.size; + + progress = Math.min(100, (noSpendDays / challenge.targetValue) * 100); + break; + } + + case 'category_reduction': { + // Compare spending in category to previous period + const periodLength = Math.ceil((challenge.endDate - challenge.startDate) / (1000 * 60 * 60 * 24)); + const previousStart = new Date(challenge.startDate); + previousStart.setDate(previousStart.getDate() - periodLength); + + const categoryQuery = challenge.category === 'all' + ? {} + : { category: challenge.category }; + + const [currentPeriod, previousPeriod] = await Promise.all([ + Expense.aggregate([ + { + $match: { + user: new mongoose.Types.ObjectId(userId), + type: 'expense', + date: { $gte: startDate, $lte: endDate }, + ...categoryQuery + } + }, + { $group: { _id: null, total: { $sum: '$amount' } } } + ]), + Expense.aggregate([ + { + $match: { + user: new mongoose.Types.ObjectId(userId), + type: 'expense', + date: { $gte: previousStart, $lt: challenge.startDate }, + ...categoryQuery + } + }, + { $group: { _id: null, total: { $sum: '$amount' } } } + ]) + ]); + + const currentTotal = currentPeriod[0]?.total || 0; + const previousTotal = previousPeriod[0]?.total || 1; + + const reduction = ((previousTotal - currentTotal) / previousTotal) * 100; + savedAmount = Math.max(0, previousTotal - currentTotal); + progress = Math.min(100, (reduction / challenge.targetValue) * 100); + break; + } + + case 'savings_target': { + // Calculate net savings + const stats = await Expense.aggregate([ + { + $match: { + user: new mongoose.Types.ObjectId(userId), + date: { $gte: startDate, $lte: endDate } + } + }, + { + $group: { + _id: '$type', + total: { $sum: '$amount' } + } + } + ]); + + let income = 0, expenses = 0; + stats.forEach(s => { + if (s._id === 'income') income = s.total; + if (s._id === 'expense') expenses = s.total; + }); + + savedAmount = Math.max(0, income - expenses); + progress = Math.min(100, (savedAmount / challenge.targetValue) * 100); + break; + } + + case 'budget_adherence': { + // Check if staying under budget + // This would integrate with the budget system + const Budget = require('../models/Budget'); + const budgets = await Budget.find({ + user: userId, + startDate: { $lte: endDate }, + endDate: { $gte: startDate } + }); + + if (budgets.length > 0) { + let underBudgetDays = 0; + const totalDays = Math.ceil((endDate - startDate) / (1000 * 60 * 60 * 24)); + + // Simplified: check if overall spending is under budget + for (const budget of budgets) { + const spent = await Expense.aggregate([ + { + $match: { + user: new mongoose.Types.ObjectId(userId), + type: 'expense', + date: { $gte: budget.startDate, $lte: budget.endDate }, + category: budget.category + } + }, + { $group: { _id: null, total: { $sum: '$amount' } } } + ]); + + if (!spent[0] || spent[0].total <= budget.amount) { + underBudgetDays += totalDays; + } + } + + progress = Math.min(100, (underBudgetDays / challenge.targetValue) * 100); + } + break; + } + + case 'streak': { + progress = Math.min(100, (participant.currentStreak / challenge.targetValue) * 100); + break; + } + + default: + // Custom challenge - use manual progress updates + break; + } + + // Update participant + await this.updateChallengeProgress(userId, challengeId, { + progress: Math.round(progress), + savedAmount + }); + + return { progress: Math.round(progress), savedAmount }; + } + + /** + * Get active challenges for user + */ + async getUserChallenges(userId, status = null) { + const query = { + 'participants.user': userId + }; + + if (status) { + query.status = status; + } + + const challenges = await Challenge.find(query) + .populate('creator', 'name') + .sort({ startDate: -1 }); + + return challenges.map(challenge => { + const participant = challenge.getParticipant(userId); + return { + ...challenge.toObject(), + userProgress: participant?.progress || 0, + userStatus: participant?.status || 'active', + userStreak: participant?.currentStreak || 0, + daysRemaining: challenge.getDaysRemaining() + }; + }); + } + + /** + * Get available public challenges + */ + async getPublicChallenges(userId, filters = {}) { + const query = { + isPublic: true, + status: { $in: ['upcoming', 'active'] } + }; + + if (filters.type) { + query.type = filters.type; + } + + if (filters.difficulty) { + query.difficulty = filters.difficulty; + } + + const challenges = await Challenge.find(query) + .populate('creator', 'name') + .sort({ startDate: 1 }) + .limit(filters.limit || 20); + + return challenges.map(challenge => ({ + ...challenge.toObject(), + isJoined: challenge.isParticipant(userId), + participantCount: challenge.participants.length, + daysRemaining: challenge.getDaysRemaining() + })); + } + + /** + * Get user achievements with progress + */ + async getUserAchievements(userId) { + const profile = await this.getOrCreateProfile(userId); + const allAchievements = await Achievement.find({ isActive: true }); + + return allAchievements.map(achievement => { + const isEarned = profile.hasAchievement(achievement._id); + const progress = profile.getProgress(achievement.code); + + // Hide secret achievements that aren't earned + if (achievement.isSecret && !isEarned) { + return { + id: achievement._id, + name: '???', + description: 'Secret achievement', + icon: '๐Ÿ”’', + category: achievement.category, + tier: achievement.tier, + isSecret: true, + isEarned: false, + progress: 0 + }; + } + + return { + id: achievement._id, + code: achievement.code, + name: achievement.name, + description: achievement.description, + icon: achievement.icon, + category: achievement.category, + tier: achievement.tier, + points: achievement.points, + rarity: achievement.rarity, + isEarned, + earnedAt: isEarned + ? profile.earnedAchievements.find(ea => ea.achievement.toString() === achievement._id.toString())?.earnedAt + : null, + progress: progress + ? Math.min(100, Math.round((progress.currentValue / progress.targetValue) * 100)) + : 0, + currentValue: progress?.currentValue || 0, + targetValue: achievement.requirement.value + }; + }); + } + + /** + * Track expense for gamification + */ + async trackExpense(userId, expense) { + const profile = await this.getOrCreateProfile(userId); + + // Update stats + profile.stats.totalExpensesTracked++; + await profile.save(); + + // Check expense tracking achievements + await this.checkAchievement(userId, 'expense_tracking', profile.stats.totalExpensesTracked); + + // Update expense tracking streak + await this.updateStreak(userId, 'expense_tracking'); + + // Award points for tracking + await this.awardPoints(userId, 1, 'Tracked an expense'); + + // Update active challenges + const activeChallenges = await Challenge.find({ + 'participants.user': userId, + status: 'active' + }); + + for (const challenge of activeChallenges) { + await this.calculateChallengeProgress(userId, challenge._id); + } + } + + /** + * Track login for streak + */ + async trackLogin(userId) { + const profile = await this.getOrCreateProfile(userId); + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const lastLogin = profile.stats.lastLoginDate + ? new Date(profile.stats.lastLoginDate) + : null; + + if (!lastLogin || lastLogin < today) { + profile.stats.lastLoginDate = now; + + // Update login streak + if (lastLogin) { + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + if (lastLogin >= yesterday) { + profile.stats.loginStreak++; + } else { + profile.stats.loginStreak = 1; + } + } else { + profile.stats.loginStreak = 1; + } + + await profile.save(); + + // Check login streak achievements + await this.checkAchievement(userId, 'login_streak', profile.stats.loginStreak); + + // Award daily login points + await this.awardPoints(userId, 2, 'Daily login'); + } + + return profile; + } + + /** + * Track analytics view + */ + async trackAnalyticsView(userId) { + const profile = await this.getOrCreateProfile(userId); + profile.stats.analyticsViews++; + await profile.save(); + + await this.checkAchievement(userId, 'analytics_usage', profile.stats.analyticsViews); + } + + /** + * Track goal completion + */ + async trackGoalCompletion(userId) { + const profile = await this.getOrCreateProfile(userId); + profile.stats.totalGoalsCompleted++; + await profile.save(); + + await this.checkAchievement(userId, 'goal_completion', profile.stats.totalGoalsCompleted); + await this.awardPoints(userId, 50, 'Completed a financial goal'); + } + + /** + * Track receipt upload + */ + async trackReceiptUpload(userId) { + const profile = await this.getOrCreateProfile(userId); + profile.stats.totalReceiptsUploaded++; + await profile.save(); + + await this.checkAchievement(userId, 'receipt_uploads', profile.stats.totalReceiptsUploaded); + await this.awardPoints(userId, 3, 'Uploaded a receipt'); + } + + /** + * Initialize default achievements + */ + async initializeDefaultAchievements() { + const defaultAchievements = [ + // Savings achievements + { + code: 'first_savings', + name: 'First Steps', + description: 'Track your first expense', + icon: '๐ŸŒฑ', + category: 'tracking', + tier: 'bronze', + points: 10, + requirement: { type: 'expense_tracking', value: 1 }, + rarity: 'common' + }, + { + code: 'expense_tracker_50', + name: 'Dedicated Tracker', + description: 'Track 50 expenses', + icon: '๐Ÿ“', + category: 'tracking', + tier: 'silver', + points: 25, + requirement: { type: 'expense_tracking', value: 50 }, + rarity: 'uncommon' + }, + { + code: 'expense_tracker_500', + name: 'Tracking Master', + description: 'Track 500 expenses', + icon: '๐Ÿ†', + category: 'tracking', + tier: 'gold', + points: 100, + requirement: { type: 'expense_tracking', value: 500 }, + rarity: 'rare' + }, + // Budget achievements + { + code: 'budget_master_7', + name: 'Budget Beginner', + description: 'Stay under budget for 7 days', + icon: '๐Ÿ’ฐ', + category: 'budgeting', + tier: 'bronze', + points: 20, + requirement: { type: 'budget_streak', value: 7 }, + rarity: 'common' + }, + { + code: 'budget_master_30', + name: 'Budget Pro', + description: 'Stay under budget for 30 days', + icon: '๐Ÿ’Ž', + category: 'budgeting', + tier: 'silver', + points: 50, + requirement: { type: 'budget_streak', value: 30 }, + rarity: 'uncommon' + }, + { + code: 'budget_master_90', + name: 'Budget Master', + description: 'Stay under budget for 3 months', + icon: '๐Ÿ‘‘', + category: 'budgeting', + tier: 'gold', + points: 150, + requirement: { type: 'budget_streak', value: 90 }, + rarity: 'rare' + }, + // Streak achievements + { + code: 'login_streak_7', + name: 'Week Warrior', + description: 'Log in 7 days in a row', + icon: '๐Ÿ”ฅ', + category: 'streaks', + tier: 'bronze', + points: 15, + requirement: { type: 'login_streak', value: 7 }, + rarity: 'common' + }, + { + code: 'login_streak_30', + name: 'Monthly Master', + description: 'Log in 30 days in a row', + icon: 'โญ', + category: 'streaks', + tier: 'silver', + points: 50, + requirement: { type: 'login_streak', value: 30 }, + rarity: 'uncommon' + }, + { + code: 'savings_streak_30', + name: 'Savings Streak', + description: 'Save consistently for 30 days', + icon: '๐Ÿ’Ž', + category: 'savings', + tier: 'gold', + points: 75, + requirement: { type: 'savings_amount', value: 30, timeframe: 'daily' }, + rarity: 'rare' + }, + // Goal achievements + { + code: 'goal_crusher_1', + name: 'Goal Getter', + description: 'Complete your first financial goal', + icon: '๐ŸŽฏ', + category: 'milestones', + tier: 'bronze', + points: 25, + requirement: { type: 'goal_completion', value: 1 }, + rarity: 'common' + }, + { + code: 'goal_crusher_5', + name: 'Goal Crusher', + description: 'Complete 5 financial goals', + icon: '๐Ÿ…', + category: 'milestones', + tier: 'gold', + points: 100, + requirement: { type: 'goal_completion', value: 5 }, + rarity: 'rare' + }, + // Challenge achievements + { + code: 'challenge_winner_1', + name: 'Challenge Accepted', + description: 'Complete your first challenge', + icon: '๐ŸŽฎ', + category: 'challenges', + tier: 'bronze', + points: 20, + requirement: { type: 'challenge_wins', value: 1 }, + rarity: 'common' + }, + { + code: 'challenge_winner_10', + name: 'Challenge Champion', + description: 'Complete 10 challenges', + icon: '๐Ÿ†', + category: 'challenges', + tier: 'gold', + points: 150, + requirement: { type: 'challenge_wins', value: 10 }, + rarity: 'rare' + }, + // Analytics achievements + { + code: 'analytics_pro', + name: 'Analytics Pro', + description: 'Check your dashboard 7 days in a row', + icon: '๐Ÿ“Š', + category: 'tracking', + tier: 'silver', + points: 30, + requirement: { type: 'analytics_usage', value: 7 }, + rarity: 'uncommon' + }, + // No spend achievements + { + code: 'no_spend_3', + name: 'Frugal Start', + description: 'Have 3 no-spend days', + icon: '๐ŸŒฟ', + category: 'savings', + tier: 'bronze', + points: 15, + requirement: { type: 'no_spend_days', value: 3 }, + rarity: 'common' + }, + { + code: 'no_spend_10', + name: 'Frugal Master', + description: 'Have 10 no-spend days', + icon: '๐ŸŒณ', + category: 'savings', + tier: 'silver', + points: 40, + requirement: { type: 'no_spend_days', value: 10 }, + rarity: 'uncommon' + }, + // Receipt achievements + { + code: 'receipt_hunter_10', + name: 'Receipt Collector', + description: 'Upload 10 receipts', + icon: '๐Ÿงพ', + category: 'tracking', + tier: 'bronze', + points: 20, + requirement: { type: 'receipt_uploads', value: 10 }, + rarity: 'common' + }, + { + code: 'receipt_hunter_50', + name: 'Receipt Master', + description: 'Upload 50 receipts', + icon: '๐Ÿ“‹', + category: 'tracking', + tier: 'silver', + points: 50, + requirement: { type: 'receipt_uploads', value: 50 }, + rarity: 'uncommon' + } + ]; + + for (const achievement of defaultAchievements) { + await Achievement.findOneAndUpdate( + { code: achievement.code }, + achievement, + { upsert: true, new: true } + ); + } + + console.log('Default achievements initialized'); + } + + /** + * Reset weekly/monthly points + */ + async resetPeriodicPoints(type) { + const now = new Date(); + + if (type === 'weekly') { + await UserGamification.updateMany( + {}, + { + $set: { + weeklyPoints: 0, + lastWeeklyReset: now + } + } + ); + } else if (type === 'monthly') { + await UserGamification.updateMany( + {}, + { + $set: { + monthlyPoints: 0, + lastMonthlyReset: now + } + } + ); + } + } + + /** + * Check and update all active challenges status + */ + async updateChallengeStatuses() { + const now = new Date(); + + // Mark started challenges as active + await Challenge.updateMany( + { + status: 'upcoming', + startDate: { $lte: now } + }, + { $set: { status: 'active' } } + ); + + // Mark ended challenges as completed + const endedChallenges = await Challenge.find({ + status: 'active', + endDate: { $lt: now } + }); + + for (const challenge of endedChallenges) { + challenge.status = 'completed'; + + // Mark remaining active participants as failed + for (const participant of challenge.participants) { + if (participant.status === 'active') { + participant.status = 'failed'; + } + } + + await challenge.save(); + } + } +} + +module.exports = new GamificationService();