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 = () => {
{info.levelName}
+Level {info.level}
++ {info.xpNeeded - info.xpIntoLevel} XP to Level {info.level + 1} +
+ )} +{info.levelName}
+Level {info.level}
++ {(info.xpNeeded - info.xpIntoLevel).toLocaleString()} XP needed to reach Level {info.level + 1} +
+ ) : ( +
+
Saved Routines
-{stats.summary.totalRoutineTasksCount} scheduled items
+Total XP
++ {xpData ? `Level ${xpData.level} · ${getLevelInfo(xpData.total).levelName}` : "—"} +
{user?.email}
Recent XP
+