diff --git a/Frontend/src/App.jsx b/Frontend/src/App.jsx index 04bfd40..c6c5900 100644 --- a/Frontend/src/App.jsx +++ b/Frontend/src/App.jsx @@ -5,6 +5,7 @@ import LoginCard from "./views/auth/login/page"; import SignUpCard from "./views/auth/signup/page"; import ProblemPage from "./views/problemPage/ProblemPage"; import Dashboard from "./views/dashboard/DashBoard"; +import UserDashboard from "./views/dashboard/UserDashboard"; import Problems from "./views/problems/problems"; import NewProblem from "./views/newProblem/NewProblem"; import ContestListPage from "./views/contest/ContestListPage"; @@ -38,6 +39,14 @@ export default function App() { }> } /> + + + + } + /> } /> } /> { withCredentials: true, }); } - +export const getUserDashboardStats = () => { + return axios.get(`${BASE}/submission/user/dashboard`, { + withCredentials: true, + }); +}; diff --git a/Frontend/src/views/dashboard/UserDashboard.jsx b/Frontend/src/views/dashboard/UserDashboard.jsx new file mode 100644 index 0000000..a58f69d --- /dev/null +++ b/Frontend/src/views/dashboard/UserDashboard.jsx @@ -0,0 +1,403 @@ +import React, { useEffect, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { useAuth } from "@/hooks/AuthContext"; +import { getUserDashboardStats } from "@/api/api"; +import { + Trophy, Code2, CheckCircle2, XCircle, Clock, Cpu, + ChevronRight, BarChart3, User, Zap, BookOpen, + MessageSquare, Target, TrendingUp, Calendar, Star +} from "lucide-react"; + +// ── Helpers ───────────────────────────────────────────────── +const VERDICT_META = { + ACCEPTED: { label: "Accepted", color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/20", dot: "bg-emerald-400" }, + WRONG_ANSWER: { label: "Wrong Answer",color: "text-rose-400", bg: "bg-rose-500/10 border-rose-500/20", dot: "bg-rose-400" }, + TIME_LIMIT_EXCEEDED: { label: "TLE", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/20", dot: "bg-amber-400" }, + MEMORY_LIMIT_EXCEEDED:{ label: "MLE", color: "text-purple-400", bg: "bg-purple-500/10 border-purple-500/20", dot: "bg-purple-400" }, + RUNTIME_ERROR: { label: "Runtime Err", color: "text-orange-400", bg: "bg-orange-500/10 border-orange-500/20", dot: "bg-orange-400" }, + COMPILE_ERROR: { label: "Compile Err", color: "text-slate-400", bg: "bg-slate-500/10 border-slate-500/20", dot: "bg-slate-400" }, + PENDING: { label: "Pending", color: "text-sky-400", bg: "bg-sky-500/10 border-sky-500/20", dot: "bg-sky-400" }, + RUNNING: { label: "Running", color: "text-sky-400", bg: "bg-sky-500/10 border-sky-500/20", dot: "bg-sky-400" }, + FAILED: { label: "Failed", color: "text-rose-400", bg: "bg-rose-500/10 border-rose-500/20", dot: "bg-rose-400" }, +}; + +const DIFF_META = { + EASY: { color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/30" }, + MEDIUM: { color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/30" }, + HARD: { color: "text-rose-400", bg: "bg-rose-500/10 border-rose-500/30" }, +}; + +const LANG_LABELS = { CPP: "C++", JAVA: "Java", PYTHON: "Python", JAVASCRIPT: "JS", C: "C", CSHARP: "C#" }; + +function timeAgo(dateStr) { + const diff = (Date.now() - new Date(dateStr)) / 1000; + if (diff < 60) return `${Math.floor(diff)}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} + +// ── Animated counter ───────────────────────────────────────── +function Counter({ target, duration = 1200 }) { + const [val, setVal] = useState(0); + useEffect(() => { + if (!target) return; + let start = 0; + const step = target / (duration / 16); + const t = setInterval(() => { + start += step; + if (start >= target) { setVal(target); clearInterval(t); } + else setVal(Math.floor(start)); + }, 16); + return () => clearInterval(t); + }, [target, duration]); + return <>{val.toLocaleString()}; +} + +// ── Donut chart ─────────────────────────────────────────────── +function DonutChart({ easy, medium, hard, total }) { + const R = 52, C = 2 * Math.PI * R; + const eP = total ? (easy / total) * C : 0; + const mP = total ? (medium / total) * C : 0; + const hP = total ? (hard / total) * C : 0; + const gap = 2; + + const Segment = ({ offset, len, color, label }) => + len > gap ? ( + + ) : null; + + return ( +
+ + + + + + +
+ {total} + Solved +
+
+ ); +} + +// ── Stat card ───────────────────────────────────────────────── +function StatCard({ icon: Icon, label, value, sub, accent, loading }) { + return ( +
+
+
+
+ +
+ {sub} +
+
+
+ {loading ?
: } +
+
{label}
+
+
+ ); +} + +// ── Submission row ──────────────────────────────────────────── +function SubRow({ sub }) { + const v = VERDICT_META[sub.status] || VERDICT_META.PENDING; + const d = DIFF_META[sub.problem?.difficulty] || {}; + return ( + +
+
+

+ {sub.problem?.title || "Unknown Problem"} +

+
+ + {sub.problem?.difficulty || "—"} + + {LANG_LABELS[sub.language] || sub.language} +
+
+
+ {v.label} + {timeAgo(sub.createdAt)} +
+ + + ); +} + +// ── Skeleton ────────────────────────────────────────────────── +function Skeleton({ className }) { + return
; +} + +// ── Main ────────────────────────────────────────────────────── +export default function UserDashboard() { + const { user, logout } = useAuth(); + const navigate = useNavigate(); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + (async () => { + try { + const res = await getUserDashboardStats(); + setStats(res.data.data); + } catch { + setStats({ totalSubmissions: 0, totalSolved: 0, solvedByDifficulty: { easy: 0, medium: 0, hard: 0 }, recentSubmissions: [] }); + } finally { + setLoading(false); + } + })(); + }, []); + + const solved = stats?.solvedByDifficulty ?? { easy: 0, medium: 0, hard: 0 }; + const totalSolved = stats?.totalSolved ?? 0; + const acceptRate = stats?.totalSubmissions + ? Math.round((totalSolved / stats.totalSubmissions) * 100) + : 0; + + const avatar = user?.username?.charAt(0)?.toUpperCase() || "U"; + const initials = user?.username?.slice(0, 2)?.toUpperCase() || "U"; + + return ( +
+ {/* Top gradient line */} +
+ +
+ + {/* ── Header ── */} +
+
+
+
+
+ {initials} +
+
+
+

+ Hey, {user?.username || "Coder"} 👋 +

+

{user?.email}

+
+
+ +
+ + + {user?.role || "USER"} + + + Solve Now + +
+
+ + {/* ── Stat Cards ── */} +
+ + + + +
+ + {/* ── Main Grid ── */} +
+ + {/* Left — Progress + Recent submissions */} +
+ + {/* Solved by difficulty */} +
+
+ +

Solved by Difficulty

+
+
+ {loading ? ( + + ) : ( + + )} +
+ {[ + { label: "Easy", count: solved.easy, color: "bg-emerald-500", text: "text-emerald-400", total: 100 }, + { label: "Medium", count: solved.medium, color: "bg-amber-500", text: "text-amber-400", total: 100 }, + { label: "Hard", count: solved.hard, color: "bg-rose-500", text: "text-rose-400", total: 100 }, + ].map(({ label, count, color, text, total }) => ( +
+
+ {label} + + {loading ? "—" : count} / {total} + +
+
+
+
+
+ ))} +
+
+
+ + {/* Recent Submissions */} +
+
+
+ +

Recent Submissions

+
+ + All Problems → + +
+ + {loading ? ( +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ ) : stats?.recentSubmissions?.length > 0 ? ( +
+ {stats.recentSubmissions.slice(0, 10).map((sub) => ( + + ))} +
+ ) : ( +
+
+ +
+

No submissions yet

+

Start solving problems to track your progress

+ + Browse Problems + +
+ )} +
+
+ + {/* Right sidebar */} +
+ + {/* Profile card */} +
+
+ +

Profile

+
+
+
+ Username + @{user?.username || "—"} +
+
+ Role + {user?.role || "USER"} +
+
+ Accept Rate + {loading ? "—" : `${acceptRate}%`} +
+
+
+
+ {[ + { label: "Easy", value: solved.easy, color: "text-emerald-400" }, + { label: "Medium", value: solved.medium, color: "text-amber-400" }, + { label: "Hard", value: solved.hard, color: "text-rose-400" }, + ].map(({ label, value, color }) => ( +
+
+ {loading ? "—" : value} +
+
{label}
+
+ ))} +
+
+ + {/* Quick Actions */} +
+
+ +

Quick Actions

+
+
+ {[ + { to: "/problems", icon: Code2, label: "Browse Problems", sub: "Solve & improve" }, + { to: "/interview",icon: BookOpen, label: "Interview Prep", sub: "AI mock interviews" }, + { to: "/contests", icon: Trophy, label: "Contests", sub: "Compete & rank" }, + { to: "/discuss", icon: MessageSquare, label: "Discussions", sub: "Learn with others" }, + ].map(({ to, icon: Icon, label, sub }) => ( + +
+ +
+
+

{label}

+

{sub}

+
+ + + ))} +
+
+ + {/* Verdict legend */} + {stats?.recentSubmissions?.length > 0 && ( +
+
+ +

Verdict Key

+
+
+ {Object.entries(VERDICT_META).slice(0, 6).map(([k, v]) => ( +
+ + {v.label} +
+ ))} +
+
+ )} +
+
+
+
+ ); +} diff --git a/backend/src/api/submission/submission-controller.ts b/backend/src/api/submission/submission-controller.ts index a667c22..2805387 100644 --- a/backend/src/api/submission/submission-controller.ts +++ b/backend/src/api/submission/submission-controller.ts @@ -1,7 +1,7 @@ import asyncHandler from "../../utils/asyncHandler"; import { jwtReq } from "../../types"; import { Response } from "express"; -import { handleCreateSubmission, handlegetSubmissionStatus, handleGetSubmissionResults, handleGetAllSubmissions } from "./submission-service"; +import { handleCreateSubmission, handlegetSubmissionStatus, handleGetSubmissionResults, handleGetAllSubmissions, handleGetUserDashboardStats } from "./submission-service"; export const createSubmission = asyncHandler(async (req: jwtReq, res: Response) => { const { problemId, mode } = req.params; @@ -50,3 +50,12 @@ export const getAllSubmissions = asyncHandler(async (req: jwtReq, res: Response) message: response }) }) + +export const getUserDashboardStats = asyncHandler(async (req: jwtReq, res: Response) => { + const userId = req.user.id; + const response = await handleGetUserDashboardStats(userId); + return res.status(200).json({ + success: true, + data: response, + }); +}); diff --git a/backend/src/api/submission/submission-route.ts b/backend/src/api/submission/submission-route.ts index 063f2eb..6bf321d 100644 --- a/backend/src/api/submission/submission-route.ts +++ b/backend/src/api/submission/submission-route.ts @@ -1,10 +1,11 @@ import { Router } from "express"; import { validate, verifyJWT } from "../../shared/middleware"; import { createSubmissionSchema } from "./submission-schema"; -import { createSubmission, getSubmissionStatus, getSubmissionResults, getAllSubmissions } from "./submission-controller"; +import { createSubmission, getSubmissionStatus, getSubmissionResults, getAllSubmissions, getUserDashboardStats } from "./submission-controller"; const router = Router(); +router.get("/user/dashboard", verifyJWT, getUserDashboardStats); router.get("/problem/:problemId", verifyJWT, getAllSubmissions); router.get("/:submissionId", verifyJWT, getSubmissionStatus); router.get("/:submissionId/result", verifyJWT, getSubmissionResults); diff --git a/backend/src/api/submission/submission-service.ts b/backend/src/api/submission/submission-service.ts index dd65d79..9d36d8f 100644 --- a/backend/src/api/submission/submission-service.ts +++ b/backend/src/api/submission/submission-service.ts @@ -2,7 +2,7 @@ import { IGetSubmissionResponse, IGetSubmissionResultsResponse } from './submiss import { submission, executionResult, user, problem } from '../../db/schema'; import { db } from '../../loaders/postgres'; import redis from '../../loaders/redis'; -import { eq, and, desc } from 'drizzle-orm'; +import { eq, and, desc, count, sql } from 'drizzle-orm'; import ApiError from '../../utils/ApiError'; import httpStatus from 'http-status'; import { createRunQueue, createSubmitQueue } from './submission-helper'; @@ -193,6 +193,95 @@ export const handleGetSubmissionResults = async ( } } +export const handleGetUserDashboardStats = async (userId: string) => { + try { + // Recent submissions (last 20 across all problems) + const recentSubs = await db + .select({ + id: submission.id, + language: submission.language, + status: submission.status, + verdict: executionResult.verdict, + timeTaken: submission.timeTaken, + memoryTaken: submission.memoryTaken, + createdAt: submission.createdAt, + problemId: submission.problemId, + problemTitle: problem.title, + problemSlug: problem.slug, + problemDifficulty: problem.difficulty, + }) + .from(submission) + .leftJoin(executionResult, eq(executionResult.submissionId, submission.id)) + .leftJoin(problem, eq(problem.id, submission.problemId)) + .where(and(eq(submission.userId, userId), eq(submission.mode, 'SUBMIT'))) + .orderBy(desc(submission.createdAt)) + .limit(20); + + // Total submissions count + const [totalRow] = await db + .select({ count: count() }) + .from(submission) + .where(and(eq(submission.userId, userId), eq(submission.mode, 'SUBMIT'))); + + // Accepted problems (distinct problems with ACCEPTED verdict) + const acceptedProblems = await db + .selectDistinct({ problemId: submission.problemId }) + .from(submission) + .innerJoin(executionResult, eq(executionResult.submissionId, submission.id)) + .where(and( + eq(submission.userId, userId), + eq(submission.mode, 'SUBMIT'), + eq(executionResult.verdict, 'ACCEPTED') + )); + + // Group accepted by difficulty + const acceptedWithDiff = await db + .selectDistinct({ problemId: submission.problemId, difficulty: problem.difficulty }) + .from(submission) + .innerJoin(executionResult, eq(executionResult.submissionId, submission.id)) + .innerJoin(problem, eq(problem.id, submission.problemId)) + .where(and( + eq(submission.userId, userId), + eq(submission.mode, 'SUBMIT'), + eq(executionResult.verdict, 'ACCEPTED') + )); + + const easyCount = acceptedWithDiff.filter(r => r.difficulty === 'EASY').length; + const mediumCount = acceptedWithDiff.filter(r => r.difficulty === 'MEDIUM').length; + const hardCount = acceptedWithDiff.filter(r => r.difficulty === 'HARD').length; + + const formatted = recentSubs.map(r => ({ + id: r.id, + language: r.language, + status: r.verdict && r.verdict !== 'PENDING' ? r.verdict : r.status, + timeTaken: r.timeTaken, + memoryTaken: r.memoryTaken, + createdAt: r.createdAt, + problem: { + id: r.problemId, + title: r.problemTitle, + slug: r.problemSlug, + difficulty: r.problemDifficulty, + }, + })); + + return { + totalSubmissions: totalRow?.count ?? 0, + totalSolved: acceptedProblems.length, + solvedByDifficulty: { easy: easyCount, medium: mediumCount, hard: hardCount }, + recentSubmissions: formatted, + }; + } catch (error) { + console.error('Dashboard stats error:', error); + return { + totalSubmissions: 0, + totalSolved: 0, + solvedByDifficulty: { easy: 0, medium: 0, hard: 0 }, + recentSubmissions: [], + }; + } +}; + export const handleGetAllSubmissions = async (userId: string, problemId: string) => { try { const results = await db