From 58daf332243861cfc67a511e91fad9166ec33882 Mon Sep 17 00:00:00 2001 From: arcgod-design Date: Wed, 1 Jul 2026 00:31:08 +0530 Subject: [PATCH 1/2] feat: leaderboard privacy toggle, 60s cache, and own-rank display (closes #13) --- app/src/sections/Leaderboard.tsx | 42 +++++++++--- app/src/sections/ProfileView.tsx | 61 ++++++++++++++++- backend/prisma/schema.prisma | 1 + backend/src/controllers/userController.ts | 82 ++++++++++++++++++++++- backend/src/routes/userRoutes.ts | 3 +- 5 files changed, 176 insertions(+), 13 deletions(-) diff --git a/app/src/sections/Leaderboard.tsx b/app/src/sections/Leaderboard.tsx index 36dc6a0..e3822b7 100644 --- a/app/src/sections/Leaderboard.tsx +++ b/app/src/sections/Leaderboard.tsx @@ -27,10 +27,11 @@ interface LeaderboardEntry { } export function Leaderboard({ onProfileClick }: LeaderboardProps) { - const { profile } = useAuth(); + const { profile, token } = useAuth(); const [timeRange, setTimeRange] = useState<'all' | 'month' | 'week'>('all'); const [category, setCategory] = useState<'xp' | 'streak' | 'solved'>('xp'); const [leaderboardData, setLeaderboardData] = useState([]); + const [myRank, setMyRank] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { @@ -52,6 +53,27 @@ export function Leaderboard({ onProfileClick }: LeaderboardProps) { fetchLeaderboard(); }, [category]); + // Fetch own rank separately (always shown, even if hidden from public leaderboard) + useEffect(() => { + if (!profile) return; + const fetchMyRank = async () => { + try { + const token = localStorage.getItem('token'); + if (!token) return; + const res = await fetch(`${API_BASE_URL}/api/users/leaderboard/me?sortBy=${category}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + const data = await res.json(); + setMyRank(data); + } + } catch { + // Silently ignore — own rank is best-effort + } + }; + fetchMyRank(); + }, [profile, category]); + const getRankIcon = (rank: number) => { if (rank === 1) return ; if (rank === 2) return ; @@ -265,8 +287,8 @@ export function Leaderboard({ onProfileClick }: LeaderboardProps) { ))} - {/* Current User Rank - TODO: Implement finding user rank from backend */} - {profile && ( + {/* Current User Rank */} + {profile && myRank && (
- #? + #{myRank.rank}
- {profile.avatar?.startsWith('http') ? ( - {profile.name} + {myRank.avatar?.startsWith('http') ? ( + {myRank.name} ) : ( - {profile.name?.charAt(0).toUpperCase() || 'Y'} + {myRank.avatar || profile.name?.charAt(0).toUpperCase() || 'Y'} )}
@@ -293,15 +315,15 @@ export function Leaderboard({ onProfileClick }: LeaderboardProps) {
- {profile.xp_points || 0} + {myRank.xp.toLocaleString()}
- {profile.streak_days || 0} + {myRank.streak}
- {profile.solvedProblems?.length || 0} + {myRank.solved}
diff --git a/app/src/sections/ProfileView.tsx b/app/src/sections/ProfileView.tsx index e48acba..92d527b 100644 --- a/app/src/sections/ProfileView.tsx +++ b/app/src/sections/ProfileView.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { motion } from 'framer-motion'; -import { Zap, Flame, Target, Edit2, Check, X, ArrowLeft, User, Award } from 'lucide-react'; +import { Zap, Flame, Target, Edit2, Check, X, ArrowLeft, User, Award, EyeOff, Eye } from 'lucide-react'; import { useAuth } from '@/contexts/AuthContext'; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000'; @@ -15,6 +15,7 @@ interface ProfileData { solved: number; level: number; memberSince: string; + isPublic?: boolean; } interface ProfileViewProps { @@ -31,6 +32,7 @@ export function ProfileView({ userId, onBack }: ProfileViewProps) { const [editBio, setEditBio] = useState(''); const [editAvatarUrl, setEditAvatarUrl] = useState(''); const [saving, setSaving] = useState(false); + const [isPublic, setIsPublic] = useState(true); const isOwner = user?.id === userId; @@ -49,6 +51,7 @@ export function ProfileView({ userId, onBack }: ProfileViewProps) { setProfile(data); setEditBio(data.bio || ''); setEditAvatarUrl(data.avatar || ''); + setIsPublic(data.isPublic !== false); } } catch (error) { console.error('Failed to fetch profile', error); @@ -92,6 +95,26 @@ export function ProfileView({ userId, onBack }: ProfileViewProps) { setIsEditing(false); }; + const handleTogglePrivacy = async () => { + if (!profile) return; + const newValue = !isPublic; + setIsPublic(newValue); + try { + const token = localStorage.getItem('token'); + await fetch(`${API_BASE_URL}/api/users/${userId}/profile`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ isPublic: newValue }), + }); + } catch { + // Revert on error + setIsPublic(!newValue); + } + }; + if (loading) { return (
@@ -217,6 +240,42 @@ export function ProfileView({ userId, onBack }: ProfileViewProps) { + {/* Privacy Toggle (owner only) */} + {isOwner && ( + + + + )} + {/* Stats */} (); +const CACHE_TTL_MS = 60_000; + +function getCachedLeaderboard(key: string) { + const entry = leaderboardCache.get(key); + if (entry && Date.now() < entry.expiresAt) return entry.data; + leaderboardCache.delete(key); + return null; +} + +function setCachedLeaderboard(key: string, data: any) { + leaderboardCache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS }); +} + // @desc Get leaderboard data // @route GET /api/users/leaderboard // @access Public export const getLeaderboard = async (req: Request, res: Response) => { try { const { limit = 10, sortBy = 'xp' } = req.query; + const cacheKey = `lb:${sortBy}:${limit}`; + + const cached = getCachedLeaderboard(cacheKey); + if (cached) { + res.setHeader('X-Cache', 'HIT'); + return res.status(200).json(cached); + } let pipeline: any[] = []; + // Filter out users who opted out of the leaderboard + pipeline.push({ + $match: { isPublic: { $ne: false } } + }); + pipeline.push({ $project: { name: 1, @@ -44,6 +71,8 @@ export const getLeaderboard = async (req: Request, res: Response) => { rank: index + 1 })); + setCachedLeaderboard(cacheKey, formattedLeaderboard); + res.setHeader('X-Cache', 'MISS'); res.status(200).json(formattedLeaderboard); } catch (error) { @@ -52,6 +81,56 @@ export const getLeaderboard = async (req: Request, res: Response) => { } }; +// @desc Get authenticated user's own rank (always shown, even if isPublic: false) +// @route GET /api/users/leaderboard/me +// @access Private +export const getMyLeaderboardRank = async (req: Request | any, res: Response) => { + try { + const userId = req.user.id; + const { sortBy = 'xp' } = req.query; + + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) return res.status(404).json({ message: 'User not found' }); + + // Count how many public users rank above this user + const sortField = sortBy === 'solved' ? 'solvedProblems' : sortBy === 'streak' ? 'streak_days' : 'xp_points'; + + let rankPipeline: any[] = [ + { $match: { isPublic: { $ne: false } } }, + ]; + + if (sortBy === 'solved') { + rankPipeline.push({ + $addFields: { solvedCount: { $size: { $ifNull: ["$solvedProblems", []] } } } + }); + rankPipeline.push({ $match: { solvedCount: { $gt: user.solvedProblems?.length || 0 } } }); + } else { + const userVal = user[sortField as keyof typeof user] as number || 0; + rankPipeline.push({ $match: { [sortField]: { $gt: userVal } } }); + } + + rankPipeline.push({ $count: 'above' }); + + const rankResult = await prisma.user.aggregateRaw({ rankPipeline }) as unknown as any[]; + const rank = (rankResult[0]?.above || 0) + 1; + + const solvedCount = user.solvedProblems?.length || 0; + + res.status(200).json({ + id: user.id, + name: user.name, + avatar: user.avatar || (user.name ? user.name.split(' ').map((n: string) => n[0]).join('').substring(0, 2).toUpperCase() : 'U'), + xp: user.xp_points || 0, + streak: user.streak_days || 0, + solved: solvedCount, + rank, + }); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Server Error' }); + } +}; + // @desc Get dashboard stats for logged-in user (rank, streak, weekly activity) // @route GET /api/users/dashboard-stats // @access Private @@ -162,7 +241,7 @@ export const getUserProfile = async (req: Request, res: Response) => { export const updateUserProfile = async (req: Request | any, res: Response) => { try { const userId = req.params.userId; - const { bio, avatarUrl } = req.body; + const { bio, avatarUrl, isPublic } = req.body; const user = await prisma.user.findUnique({ where: { id: userId } }); @@ -179,6 +258,7 @@ export const updateUserProfile = async (req: Request | any, res: Response) => { data: { bio: bio !== undefined ? bio : user.bio, avatarUrl: avatarUrl !== undefined ? avatarUrl : user.avatarUrl, + isPublic: isPublic !== undefined ? Boolean(isPublic) : user.isPublic, }, }); diff --git a/backend/src/routes/userRoutes.ts b/backend/src/routes/userRoutes.ts index c11a420..110f1d6 100644 --- a/backend/src/routes/userRoutes.ts +++ b/backend/src/routes/userRoutes.ts @@ -1,10 +1,11 @@ import express from 'express'; -import { getLeaderboard, getDashboardStats, getUserProfile, updateUserProfile } from '../controllers/userController'; +import { getLeaderboard, getMyLeaderboardRank, getDashboardStats, getUserProfile, updateUserProfile } from '../controllers/userController'; import { protect } from '../middleware/authMiddleware'; const router = express.Router(); router.get('/leaderboard', getLeaderboard); +router.get('/leaderboard/me', protect, getMyLeaderboardRank); router.get('/dashboard-stats', protect, getDashboardStats); router.get('/:userId/profile', getUserProfile); router.put('/:userId/profile', protect, updateUserProfile); From d85f69e619cf72e0df0c91ebd9caa967876539e2 Mon Sep 17 00:00:00 2001 From: arcgod-design Date: Tue, 7 Jul 2026 13:45:19 +0530 Subject: [PATCH 2/2] fix(backend): correct aggregateRaw property name rankPipeline -> pipeline (closes type error TS2561) --- backend/src/controllers/userController.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/controllers/userController.ts b/backend/src/controllers/userController.ts index 1813277..0755161 100644 --- a/backend/src/controllers/userController.ts +++ b/backend/src/controllers/userController.ts @@ -111,7 +111,7 @@ export const getMyLeaderboardRank = async (req: Request | any, res: Response) => rankPipeline.push({ $count: 'above' }); - const rankResult = await prisma.user.aggregateRaw({ rankPipeline }) as unknown as any[]; + const rankResult = await prisma.user.aggregateRaw({ pipeline: rankPipeline }) as unknown as any[]; const rank = (rankResult[0]?.above || 0) + 1; const solvedCount = user.solvedProblems?.length || 0;