diff --git a/backend/controllers/XpController.js b/backend/controllers/XpController.js new file mode 100644 index 00000000..2c26d6f0 --- /dev/null +++ b/backend/controllers/XpController.js @@ -0,0 +1,49 @@ +import User from "../src/models/User.js"; +import { getLevelFromXP, LEVEL_THRESHOLDS, LEVEL_NAMES } from "../utils/xpUtils.js"; + +/** + * GET /api/xp + * Returns current XP, level info, streak, and recent XP history for the user. + */ +export const getXP = async (req, res) => { + try { + const user = await User.findById(req.userId).select( + "xp level streak lastActivityDate xpHistory" + ); + + if (!user) { + return res.status(404).json({ success: false, message: "User not found" }); + } + + const currentLevel = getLevelFromXP(user.xp); + const currentXPFloor = LEVEL_THRESHOLDS[currentLevel - 1] ?? 0; + const nextXPCeil = LEVEL_THRESHOLDS[currentLevel] ?? LEVEL_THRESHOLDS[LEVEL_THRESHOLDS.length - 1]; + const xpIntoLevel = user.xp - currentXPFloor; + const xpNeededForNext = nextXPCeil - currentXPFloor; + const progressPercent = currentLevel >= LEVEL_THRESHOLDS.length + ? 100 + : Math.min(100, Math.floor((xpIntoLevel / xpNeededForNext) * 100)); + + const recentHistory = [...(user.xpHistory || [])] + .sort((a, b) => new Date(b.earnedAt) - new Date(a.earnedAt)) + .slice(0, 10); + + return res.status(200).json({ + success: true, + xp: { + total: user.xp, + level: currentLevel, + levelName: LEVEL_NAMES[currentLevel - 1] ?? "Legend", + currentLevelXP: xpIntoLevel, + nextLevelXP: xpNeededForNext, + progressPercent, + streak: user.streak, + lastActivityDate: user.lastActivityDate, + recentHistory, + }, + }); + } catch (error) { + console.error("Error fetching XP:", error); + return res.status(500).json({ success: false, message: "Error fetching XP data" }); + } +}; \ No newline at end of file diff --git a/backend/controllers/authController.js b/backend/controllers/authController.js index ea89f136..1f60f8bb 100644 --- a/backend/controllers/authController.js +++ b/backend/controllers/authController.js @@ -87,7 +87,7 @@ export const signup = async (req, res) => { .cookie('token', token, getAuthCookieOptions()) .json({ message: 'User registered successfully', - user: { _id: newUser._id, name: newUser.name, email: newUser.email, primaryColor: newUser.primaryColor }, + user: { _id: newUser._id, name: newUser.name, email: newUser.email, primaryColor: newUser.primaryColor, xp: newUser.xp, level: newUser.level, streak: newUser.streak }, }); } catch (_error) { console.error('Signup error:', _error); @@ -136,7 +136,7 @@ export const login = async (req, res) => { .cookie('token', token, getAuthCookieOptions()) .json({ message: 'Login successful', - user: { _id: user._id, name: user.name, email: user.email, primaryColor: user.primaryColor }, + user: { _id: user._id, name: user.name, email: user.email, primaryColor: user.primaryColor, xp: user.xp, level: user.level, streak: user.streak }, }); } catch (_error) { console.log('Login error: ', _error); @@ -336,7 +336,7 @@ export const updateProfile = async (req, res) => { return res.status(200).json({ success: true, message: 'Profile updated successfully', - user: { _id: user._id, name: user.name, email: user.email, primaryColor: user.primaryColor }, + user: { _id: user._id, name: user.name, email: user.email, primaryColor: user.primaryColor, xp: user.xp, level: user.level, streak: user.streak }, }); } catch (_error) { console.log('Profile update error:', _error); @@ -403,7 +403,7 @@ export const googleLogin = async (req, res) => { .cookie('token', token, getAuthCookieOptions()) .json({ message: 'Google sign-in successful', - user: { _id: user._id, name: user.name, email: user.email, primaryColor: user.primaryColor }, + user: { _id: user._id, name: user.name, email: user.email, primaryColor: user.primaryColor, xp: user.xp, level: user.level, streak: user.streak }, }); } catch (_error) { console.error('[GOOGLE AUTH] Controller error:', _error); diff --git a/backend/controllers/taskController.js b/backend/controllers/taskController.js index 4f7334cd..3299d82f 100644 --- a/backend/controllers/taskController.js +++ b/backend/controllers/taskController.js @@ -3,7 +3,12 @@ import Task from "../src/models/Task.js"; import User from "../src/models/User.js"; import { validationResult } from "express-validator"; import mongoose from "mongoose"; - +import { + XP_VALUES, + getLevelFromXP, + calculateNewStreak, + getStreakBonus, +} from "../utils/xpUtils.js"; const escapeRegex = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const DEFAULT_TASK_PAGE = 1; const DEFAULT_TASK_LIMIT = 10; @@ -227,15 +232,47 @@ export const updateTask = async (req, res) => { { new: true, runValidators: true } ); - if (!updatedTask) { + if (!updatedTask) { return res.status(404).json({ message: "Task not found", }); } + // ─── Award XP when a task is marked Completed ──────────────────────────── + let xpAwarded = 0; + const xpEvents = []; + + const wasCompleted = req.body.status === "Completed"; + if (wasCompleted) { + const taskUser = await User.findById(userId); + + const baseXP = XP_VALUES[updatedTask.priority] ?? XP_VALUES.Medium; + xpAwarded += baseXP; + xpEvents.push({ amount: baseXP, reason: `Completed task: ${updatedTask.title}`, earnedAt: new Date() }); + + const newStreak = calculateNewStreak(taskUser.streak, taskUser.lastActivityDate); + + const streakBonus = getStreakBonus(newStreak); + if (streakBonus) { + xpAwarded += streakBonus.bonusXP; + xpEvents.push({ amount: streakBonus.bonusXP, reason: streakBonus.label, earnedAt: new Date() }); + } + + const newTotalXP = (taskUser.xp || 0) + xpAwarded; + const newLevel = getLevelFromXP(newTotalXP); + + await User.findByIdAndUpdate(userId, { + $inc: { xp: xpAwarded }, + $set: { level: newLevel, streak: newStreak, lastActivityDate: new Date() }, + $push: { xpHistory: { $each: xpEvents } }, + }); + } + // ───────────────────────────────────────────────────────────────────────── + return res.status(200).json({ message: "Task updated successfully", task: updatedTask, + ...(xpAwarded > 0 && { xpAwarded, xpEvents }), }); } catch (error) { // error handling diff --git a/backend/package-lock.json b/backend/package-lock.json index 37c2e4d6..78033f45 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -309,6 +309,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, diff --git a/backend/routes/xpRoutes.js b/backend/routes/xpRoutes.js new file mode 100644 index 00000000..825483f9 --- /dev/null +++ b/backend/routes/xpRoutes.js @@ -0,0 +1,8 @@ +import express from "express"; +import { getXP } from "../controllers/XpController.js"; +import { authMiddleware } from "../middlewares/authMiddleware.js"; + +export const xpRouter = express.Router(); + +// GET /api/xp — fetch current user XP data +xpRouter.get("/", authMiddleware, getXP); \ No newline at end of file diff --git a/backend/src/models/User.js b/backend/src/models/User.js index bc32a495..472f38b3 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -42,6 +42,37 @@ const userSchema = new mongoose.Schema( primaryColor: { type: String, default: '#3b82f6', + // ─── XP & Gamification ─────────────────────────────────────────────────── + xp: { + type: Number, + default: 0, + min: 0, + }, + level: { + type: Number, + default: 1, + min: 1, + }, + streak: { + type: Number, + default: 0, + min: 0, + }, + lastActivityDate: { + type: Date, + default: null, + }, + xpHistory: { + type: [ + { + amount: { type: Number, required: true }, + reason: { type: String, required: true }, + earnedAt: { type: Date, default: Date.now }, + }, + ], + default: [], + }, + // ──────────────────────────────────────────────────────────────────────── }, // ──────────────────────────────────────────────────────────────────────── }, diff --git a/backend/src/server.js b/backend/src/server.js index c9ecadf0..c82a766d 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -8,6 +8,7 @@ import { authRouter } from "../routes/authRoutes.js"; import { taskRouter } from "../routes/taskRoutes.js"; import { routineRouter } from "../routes/routineRoutes.js"; import { analyticsRouter } from "../routes/analyticsRoutes.js"; +import { xpRouter } from "../routes/xpRoutes.js"; // dotenv config dotenv.config({ path: path.resolve(import.meta.dirname, "../.env") }); @@ -46,6 +47,7 @@ app.use("/api/routines", routineRouter); // Router for accessing analytics routes app.use("/api/analytics", analyticsRouter); +app.use("/api/xp", xpRouter); app.get("/", (req, res) => { res.send("Server running"); diff --git a/backend/utils/xpUtils.js b/backend/utils/xpUtils.js new file mode 100644 index 00000000..3111c617 --- /dev/null +++ b/backend/utils/xpUtils.js @@ -0,0 +1,84 @@ +// ─── XP Configuration ───────────────────────────────────────────────────────── + +// XP awarded per task based on priority +export const XP_VALUES = { + Low: 10, + Medium: 20, + High: 35, +}; + +// Bonus XP for completing ALL tasks in a single day +export const DAILY_COMPLETION_BONUS = 50; + +// Streak bonus thresholds: { minDays, bonusXP, label } +export const STREAK_BONUSES = [ + { minDays: 30, bonusXP: 100, label: "30-Day Streak" }, + { minDays: 14, bonusXP: 60, label: "2-Week Streak" }, + { minDays: 7, bonusXP: 40, label: "7-Day Streak" }, + { minDays: 3, bonusXP: 15, label: "3-Day Streak" }, +]; + +// Level thresholds — total XP needed to reach each level (index = level) +export const LEVEL_THRESHOLDS = [ + 0, // Level 1 + 100, // Level 2 + 250, // Level 3 + 500, // Level 4 + 850, // Level 5 + 1300, // Level 6 + 1900, // Level 7 + 2700, // Level 8 + 3700, // Level 9 + 5000, // Level 10 +]; + +export const LEVEL_NAMES = [ + "Newcomer", + "Apprentice", + "Explorer", + "Achiever", + "Challenger", + "Specialist", + "Expert", + "Master", + "Grandmaster", + "Legend", +]; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +export function getLevelFromXP(totalXP) { + let level = 1; + for (let i = LEVEL_THRESHOLDS.length - 1; i >= 0; i--) { + if (totalXP >= LEVEL_THRESHOLDS[i]) { + level = i + 1; + break; + } + } + return level; +} + +export function getStreakBonus(streakDays) { + for (const tier of STREAK_BONUSES) { + if (streakDays >= tier.minDays && streakDays % tier.minDays === 0) { + return { bonusXP: tier.bonusXP, label: tier.label }; + } + } + return null; +} + +export function calculateNewStreak(currentStreak, lastActivityDate) { + const now = new Date(); + const todayUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + + if (!lastActivityDate) return 1; + + const last = new Date(lastActivityDate); + const lastUTC = new Date(Date.UTC(last.getUTCFullYear(), last.getUTCMonth(), last.getUTCDate())); + + const diffDays = Math.round((todayUTC - lastUTC) / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) return currentStreak; + if (diffDays === 1) return currentStreak + 1; + return 1; +} \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 96381df8..b5e278e1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -19,6 +19,7 @@ import Pomodoro from "./pages/Pomodoro.jsx"; import ErrorBoundary from "./components/ErrorBoundary.jsx"; import PageTransition from "./components/PageTransition.jsx"; import ShareRoutine from "./pages/ShareRoutine.jsx"; +import XPToast from "./components/XP/XPToast.jsx"; const AuthLayout = ({ children }) => ( @@ -154,6 +155,7 @@ const App = () => {