diff --git a/app/src/sections/DailyChallenges.tsx b/app/src/sections/DailyChallenges.tsx index 188e23d..466f552 100644 --- a/app/src/sections/DailyChallenges.tsx +++ b/app/src/sections/DailyChallenges.tsx @@ -15,6 +15,7 @@ import { getAllProblems } from '@/api/content'; import { updateProblemStatus, getUserProgress } from '@/api/userActions'; import { useAuth } from '@/contexts/AuthContext'; import { toast } from 'sonner'; +import { SOLVE_XP } from '@/utils/xpConfig'; interface DailyChallengesProps { onBack: () => void; @@ -140,7 +141,7 @@ export function DailyChallenges({ onBack }: DailyChallengesProps) { try { await updateProblemStatus(problemMongoId, wasCompleted ? 'TODO' : 'SOLVED'); - if (!wasCompleted) toast.success('Challenge problem solved! +25 XP'); + if (!wasCompleted) toast.success(`Challenge problem solved! +${SOLVE_XP} XP`); refreshProfile(); } catch { setCompletedProblems(prev => { @@ -205,7 +206,7 @@ export function DailyChallenges({ onBack }: DailyChallengesProps) {
- {challengesSolved * 25} XP Earned + {challengesSolved * SOLVE_XP} XP Earned
@@ -309,7 +310,7 @@ export function DailyChallenges({ onBack }: DailyChallengesProps) {
- {isCompleted ? 'Earned' : 'Reward'}: +25 XP + {isCompleted ? 'Earned' : 'Reward'}: +{SOLVE_XP} XP
diff --git a/app/src/sections/Dashboard.tsx b/app/src/sections/Dashboard.tsx index b546cba..e4923a8 100644 --- a/app/src/sections/Dashboard.tsx +++ b/app/src/sections/Dashboard.tsx @@ -23,6 +23,7 @@ import { import { useAuth } from '@/contexts/AuthContext'; import { getAllProblems, getAllTopics } from '@/api/content'; import { getUserProgress, getDashboardStats } from '@/api/userActions'; +import { SOLVE_XP, XP_PER_LEVEL, calculateLevel } from '@/utils/xpConfig'; interface DashboardProps { onNavigate: (view: 'home' | 'dashboard' | 'topic' | 'problems' | 'notes' | 'leaderboard' | 'daily-challenges', topicId?: string) => void; @@ -140,7 +141,7 @@ export function Dashboard({ onNavigate }: DashboardProps) { const totalSolved = solvedIds.size; const totalProblems = problems.length; - const xpPoints = profile?.xp_points ?? totalSolved * 25; + const xpPoints = profile?.xp_points ?? totalSolved * SOLVE_XP; let easy = 0, medium = 0, hard = 0; let easyTotal = 0, mediumTotal = 0, hardTotal = 0; @@ -281,7 +282,7 @@ export function Dashboard({ onNavigate }: DashboardProps) { return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }; } - const level = Math.floor((stats.xpPoints) / 1000) + 1; + const level = calculateLevel(stats.xpPoints); if (loading) { return ( @@ -407,6 +408,8 @@ export function Dashboard({ onNavigate }: DashboardProps) { > {[ { label: 'Problems Solved', value: animSolved, sub: `of ${stats.totalProblems}`, icon: CheckCircle2, color: '#a088ff', glow: 'rgba(160,136,255,0.15)' }, + { label: 'XP Points', value: animXP, sub: `Level ${level}`, icon: Zap, color: '#ffd700', glow: 'rgba(255,215,0,0.12)', tooltip: `Earn ${SOLVE_XP} XP per solved problem. Every ${XP_PER_LEVEL.toLocaleString()} XP = 1 Level.` }, + { label: 'Day Streak', value: animStreak, sub: stats.currentStreak > 0 ? 'Keep it up!' : 'Solve to start!', icon: Flame, color: '#ff8a63', glow: 'rgba(255,138,99,0.12)' }, { label: 'XP Points', value: animXP, sub: `Level ${level}`, icon: Zap, color: '#ffd700', glow: 'rgba(255,215,0,0.12)' }, { label: 'Day Streak', value: animStreak, sub: 'Resets at midnight UTC', icon: Flame, color: '#ff8a63', glow: 'rgba(255,138,99,0.12)' }, { label: 'Global Rank', value: `#${rankInfo.rank}`, sub: `Top ${rankInfo.topPercent}%`, icon: Trophy, color: '#88ff9f', glow: 'rgba(136,255,159,0.12)' }, @@ -416,7 +419,17 @@ export function Dashboard({ onNavigate }: DashboardProps) { whileHover={{ scale: 1.03, y: -4 }} transition={{ type: 'spring', stiffness: 400, damping: 25 }} className="relative glass rounded-2xl p-5 overflow-hidden group cursor-default" + role="status" + aria-label={`${stat.label}: ${stat.value}`} > + {stat.tooltip && ( +
+
+
?
+
+ {stat.tooltip} +
+
{isRefreshing && (
@@ -831,7 +844,7 @@ export function Dashboard({ onNavigate }: DashboardProps) {
- +25 XP + +{SOLVE_XP} XP ); }) : ( diff --git a/app/src/sections/Problems.tsx b/app/src/sections/Problems.tsx index 727622c..f13c879 100644 --- a/app/src/sections/Problems.tsx +++ b/app/src/sections/Problems.tsx @@ -22,6 +22,7 @@ import { updateProblemStatus, toggleBookmark as apiToggleBookmark, getUserProgre import { useAuth } from '@/contexts/AuthContext'; import { Input } from '@/components/ui/input'; import { toast } from 'sonner'; +import { SOLVE_XP } from '@/utils/xpConfig'; export function Problems() { const { data: problemsData = [], isLoading: problemsLoading } = useQuery({ @@ -125,7 +126,7 @@ export function Problems() { try { await updateProblemStatus(problemMongoId, wasCompleted ? 'TODO' : 'SOLVED'); - if (!wasCompleted) toast.success('Problem marked as complete! +25 XP'); + if (!wasCompleted) toast.success(`Problem marked as complete! +${SOLVE_XP} XP`); // Refresh profile so nav XP updates immediately refreshProfile(); } catch (e) { diff --git a/app/src/sections/TopicDetail.tsx b/app/src/sections/TopicDetail.tsx index 1f3d75e..3d83135 100644 --- a/app/src/sections/TopicDetail.tsx +++ b/app/src/sections/TopicDetail.tsx @@ -18,6 +18,7 @@ import { updateProblemStatus, toggleBookmark as apiToggleBookmark, getUserProgre import { useAuth } from '@/contexts/AuthContext'; import { Input } from '@/components/ui/input'; import { toast } from 'sonner'; +import { SOLVE_XP } from '@/utils/xpConfig'; interface TopicDetailProps { topicId: string; @@ -126,7 +127,7 @@ export function TopicDetail({ topicId, onBack }: TopicDetailProps) { try { await updateProblemStatus(problemMongoId, wasCompleted ? 'TODO' : 'SOLVED'); - if (!wasCompleted) toast.success('Problem marked as complete! +25 XP'); + if (!wasCompleted) toast.success(`Problem marked as complete! +${SOLVE_XP} XP`); // Refresh profile so nav XP updates immediately refreshProfile(); } catch (e) { diff --git a/app/src/utils/xpConfig.ts b/app/src/utils/xpConfig.ts new file mode 100644 index 0000000..47f6f55 --- /dev/null +++ b/app/src/utils/xpConfig.ts @@ -0,0 +1,20 @@ +/** + * XP and Level Configuration + * + * Central source of truth for all XP values and level calculations. + * Change these values once — they propagate everywhere. + */ + +/** XP awarded for solving a problem */ +export const SOLVE_XP = 25; + +/** XP required per level */ +export const XP_PER_LEVEL = 1000; + +/** + * Calculate a user's level from their total XP. + * Formula: level = floor(xp / XP_PER_LEVEL) + 1 + */ +export function calculateLevel(xpPoints: number): number { + return Math.floor(xpPoints / XP_PER_LEVEL) + 1; +} diff --git a/backend/src/config/xpConfig.ts b/backend/src/config/xpConfig.ts new file mode 100644 index 0000000..47f6f55 --- /dev/null +++ b/backend/src/config/xpConfig.ts @@ -0,0 +1,20 @@ +/** + * XP and Level Configuration + * + * Central source of truth for all XP values and level calculations. + * Change these values once — they propagate everywhere. + */ + +/** XP awarded for solving a problem */ +export const SOLVE_XP = 25; + +/** XP required per level */ +export const XP_PER_LEVEL = 1000; + +/** + * Calculate a user's level from their total XP. + * Formula: level = floor(xp / XP_PER_LEVEL) + 1 + */ +export function calculateLevel(xpPoints: number): number { + return Math.floor(xpPoints / XP_PER_LEVEL) + 1; +} diff --git a/backend/src/controllers/userActionController.ts b/backend/src/controllers/userActionController.ts index 7f36924..798149c 100644 --- a/backend/src/controllers/userActionController.ts +++ b/backend/src/controllers/userActionController.ts @@ -1,5 +1,6 @@ import { Request, Response } from 'express'; import { prisma } from '../config/db'; +import { SOLVE_XP } from '../config/xpConfig'; export const updateProblemStatus = async (req: Request | any, res: Response) => { try { @@ -75,7 +76,7 @@ export const updateProblemStatus = async (req: Request | any, res: Response) => await prisma.user.update({ where: { id: userId }, data: { - xp_points: { increment: 25 }, + xp_points: { increment: SOLVE_XP }, solvedProblems: { push: [{ problemId, solvedAt: today }] }, streak_days: newStreak, last_active: today, @@ -90,7 +91,7 @@ export const updateProblemStatus = async (req: Request | any, res: Response) => await prisma.user.update({ where: { id: userId }, data: { - xp_points: { decrement: 25 }, + xp_points: { decrement: SOLVE_XP }, solvedProblems: updatedSolvedProblems } }); diff --git a/backend/src/controllers/userController.ts b/backend/src/controllers/userController.ts index 7aeefc9..afed119 100644 --- a/backend/src/controllers/userController.ts +++ b/backend/src/controllers/userController.ts @@ -1,5 +1,6 @@ import { Request, Response } from 'express'; import { prisma } from '../config/db'; +import { calculateLevel } from '../config/xpConfig'; // @desc Get leaderboard data // @route GET /api/users/leaderboard @@ -148,7 +149,7 @@ export const getUserProfile = async (req: Request, res: Response) => { xp: user.xp_points || 0, streak: user.streak_days || 0, solved: user.solvedProblems?.length || 0, - level: Math.floor((user.xp_points || 0) / 100) + 1, + level: calculateLevel(user.xp_points || 0), memberSince: user.createdAt, }); } catch (error) { @@ -189,7 +190,7 @@ export const updateUserProfile = async (req: Request | any, res: Response) => { xp: updatedUser.xp_points, streak: updatedUser.streak_days, solved: updatedUser.solvedProblems?.length || 0, - level: Math.floor((updatedUser.xp_points || 0) / 100) + 1, + level: calculateLevel(updatedUser.xp_points || 0), memberSince: updatedUser.createdAt, }); } catch (error) {