From 7f82e640e0d414e198783d4f7859a06d46026a99 Mon Sep 17 00:00:00 2001
From: shafin-r
Date: Tue, 10 Feb 2026 19:32:46 +0600
Subject: [PATCH] feat(results): add resutls page
fix(leaderboard): fix leaderboard fetch logic
fix(test): fix navigation bug upon test quit
---
index.html | 1 +
src/components/CircularLevelProgress.tsx | 145 +++++++++++++++++
src/components/ConfettiBurst.tsx | 44 ++++++
src/components/GeoGebraGraph.tsx | 93 +++++++++++
src/index.css | 35 +++++
src/pages/student/Home.tsx | 8 +-
src/pages/student/Practice.tsx | 1 +
src/pages/student/Rewards.tsx | 7 +-
src/pages/student/practice/Pretest.tsx | 2 +-
src/pages/student/practice/Results.tsx | 155 ++++++++++++++++++-
src/pages/student/practice/Test.tsx | 24 ++-
src/pages/student/targeted-practice/page.tsx | 44 +-----
src/stores/useExamConfigStore.ts | 5 +-
src/stores/useResults.ts | 16 ++
src/stores/useSatExam.ts | 48 +++---
src/types/sheet.ts | 2 +-
src/types/test.ts | 12 ++
17 files changed, 560 insertions(+), 82 deletions(-)
create mode 100644 src/components/CircularLevelProgress.tsx
create mode 100644 src/components/ConfettiBurst.tsx
create mode 100644 src/components/GeoGebraGraph.tsx
create mode 100644 src/stores/useResults.ts
diff --git a/index.html b/index.html
index f79fac2..6e195c5 100644
--- a/index.html
+++ b/index.html
@@ -4,6 +4,7 @@
+
Edbridge Scholars
diff --git a/src/components/CircularLevelProgress.tsx b/src/components/CircularLevelProgress.tsx
new file mode 100644
index 0000000..f080c0a
--- /dev/null
+++ b/src/components/CircularLevelProgress.tsx
@@ -0,0 +1,145 @@
+import { useEffect, useState } from "react";
+import { ConfettiBurst } from "./ConfettiBurst";
+
+type Props = {
+ size?: number;
+ strokeWidth?: number;
+ previousXP: number;
+ gainedXP: number;
+ levelMinXP: number;
+ levelMaxXP: number;
+ level: number;
+};
+
+export const CircularLevelProgress = ({
+ size = 300,
+ strokeWidth = 16,
+ previousXP,
+ gainedXP,
+ levelMinXP,
+ levelMaxXP,
+ level,
+}: Props) => {
+ const radius = (size - strokeWidth) / 2;
+ const circumference = 2 * Math.PI * radius;
+ const levelRange = levelMaxXP - levelMinXP;
+
+ const normalize = (xp: number) =>
+ Math.min(Math.max(xp - levelMinXP, 0), levelRange) / levelRange;
+
+ const [progress, setProgress] = useState(normalize(previousXP));
+ const [currentLevel, setCurrentLevel] = useState(level);
+ const [showLevelUp, setShowLevelUp] = useState(false);
+ const [showThresholdText, setShowThresholdText] = useState(false);
+
+ useEffect(() => {
+ let animationFrame: number;
+ let start: number | null = null;
+
+ const availableXP = previousXP + gainedXP;
+ const crossesLevel = availableXP >= levelMaxXP;
+
+ const phase1Target = crossesLevel ? 1 : normalize(previousXP + gainedXP);
+
+ const leftoverXP = crossesLevel ? availableXP - levelMaxXP : 0;
+
+ const duration = 1200;
+
+ const animatePhase1 = (timestamp: number) => {
+ if (!start) start = timestamp;
+ const t = Math.min((timestamp - start) / duration, 1);
+
+ setProgress(
+ normalize(previousXP) + t * (phase1Target - normalize(previousXP)),
+ );
+
+ if (t < 1) {
+ animationFrame = requestAnimationFrame(animatePhase1);
+ } else if (crossesLevel) {
+ setShowLevelUp(true);
+ setTimeout(startPhase2, 1200);
+ } else {
+ setShowThresholdText(true);
+ }
+ };
+
+ const startPhase2 = () => {
+ start = null;
+ setShowLevelUp(false);
+ setCurrentLevel((l) => l + 1);
+ setProgress(0);
+
+ const target = Math.min(leftoverXP / levelRange, 1);
+
+ const animatePhase2 = (timestamp: number) => {
+ if (!start) start = timestamp;
+ const t = Math.min((timestamp - start) / duration, 1);
+
+ setProgress(t * target);
+
+ if (t < 1) {
+ animationFrame = requestAnimationFrame(animatePhase2);
+ } else {
+ setShowThresholdText(true);
+ }
+ };
+
+ animationFrame = requestAnimationFrame(animatePhase2);
+ };
+
+ animationFrame = requestAnimationFrame(animatePhase1);
+
+ return () => cancelAnimationFrame(animationFrame);
+ }, []);
+
+ const offset = circumference * (1 - progress);
+
+ return (
+
+ {showLevelUp &&
}
+
+
+
+
+ {currentLevel}
+
+ {showThresholdText && (
+
+ Total XP: {previousXP + gainedXP}
+
+ )}
+
+ {showLevelUp && (
+
+ 🎉 You leveled up!
+
+ )}
+
+
+
+ );
+};
diff --git a/src/components/ConfettiBurst.tsx b/src/components/ConfettiBurst.tsx
new file mode 100644
index 0000000..8c709d5
--- /dev/null
+++ b/src/components/ConfettiBurst.tsx
@@ -0,0 +1,44 @@
+import { useEffect } from "react";
+
+type ConfettiBurstProps = {
+ count?: number;
+};
+
+export const ConfettiBurst = ({ count = 30 }: ConfettiBurstProps) => {
+ useEffect(() => {
+ const timeout = setTimeout(() => {
+ const container = document.getElementById("confetti-container");
+ if (container) container.innerHTML = "";
+ }, 1200);
+
+ return () => clearTimeout(timeout);
+ }, []);
+
+ return (
+
+ {Array.from({ length: count }).map((_, i) => (
+
+ ))}
+
+ );
+};
+
+const CONFETTI_COLORS = [
+ "#a855f7", // purple
+ "#6366f1", // indigo
+ "#ec4899", // pink
+ "#22c55e", // green
+ "#facc15", // yellow
+];
diff --git a/src/components/GeoGebraGraph.tsx b/src/components/GeoGebraGraph.tsx
new file mode 100644
index 0000000..a89fc61
--- /dev/null
+++ b/src/components/GeoGebraGraph.tsx
@@ -0,0 +1,93 @@
+import { useEffect, useRef } from "react";
+
+declare global {
+ interface Window {
+ GGBApplet: any;
+ }
+}
+
+interface GraphProps {
+ width?: string;
+ height?: string;
+ commands?: string[];
+ defaultZoom?: number;
+}
+
+export function Graph({
+ width = "w-full",
+ height = "h-30",
+ commands = [],
+ defaultZoom = 1,
+}: GraphProps) {
+ const containerRef = useRef(null);
+ const appRef = useRef(null);
+
+ useEffect(() => {
+ if (!(window as any).GGBApplet) {
+ console.error("GeoGebra library not loaded");
+ return;
+ }
+
+ const applet = new window.GGBApplet(
+ {
+ appName: "graphing",
+ width: 480,
+ height: 320,
+ scale: 1.4,
+
+ showToolBar: false,
+ showAlgebraInput: false,
+ showMenuBar: false,
+ showResetIcon: false,
+
+ enableRightClick: false,
+ enableLabelDrags: false,
+ enableShiftDragZoom: true,
+ showZoomButtons: true,
+
+ appletOnLoad(api: any) {
+ appRef.current = api;
+
+ api.setPerspective("G");
+ api.setMode(0);
+ api.setAxesVisible(true, true);
+ api.setGridVisible(true);
+
+ api.setCoordSystem(-5, 5, -5, 5);
+
+ commands.forEach((command, i) => {
+ const name = `f${i}`;
+ api.evalCommand(`${name}: ${command}`);
+ api.setFixed(name, true);
+ });
+
+ // Inside appletOnLoad:
+ },
+ },
+ true,
+ );
+
+ applet.inject("ggb-container");
+ }, [commands, defaultZoom]);
+
+ useEffect(() => {
+ const resize = () => {
+ if (!containerRef.current || !appRef.current) return;
+ appRef.current.setSize(
+ containerRef.current.offsetWidth,
+ containerRef.current.offsetHeight,
+ );
+ };
+
+ window.addEventListener("resize", resize);
+ resize(); // initial resize
+
+ return () => window.removeEventListener("resize", resize);
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/src/index.css b/src/index.css
index 37a196a..15edf4e 100644
--- a/src/index.css
+++ b/src/index.css
@@ -281,3 +281,38 @@
@apply bg-background text-foreground;
}
}
+
+@keyframes fade-in {
+ from {
+ opacity: 0;
+ transform: translateY(6px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.animate-fade-in {
+ animation: fade-in 300ms ease-out forwards;
+}
+.confetti {
+ position: absolute;
+ top: -10px;
+ width: 8px;
+ height: 14px;
+ opacity: 0.9;
+ animation: confetti-fall 1.2s ease-out forwards;
+}
+
+@keyframes confetti-fall {
+ 0% {
+ transform: translateY(0) rotate(0deg);
+ opacity: 1;
+ }
+ 100% {
+ transform: translateY(220px) rotate(720deg);
+ opacity: 0;
+ }
+}
+
diff --git a/src/pages/student/Home.tsx b/src/pages/student/Home.tsx
index 20cf011..d204916 100644
--- a/src/pages/student/Home.tsx
+++ b/src/pages/student/Home.tsx
@@ -35,8 +35,6 @@ export const Home = () => {
const user = useAuthStore((state) => state.user);
const navigate = useNavigate();
- // const logout = useAuthStore((state) => state.logout);
- // const navigate = useNavigate();
const [practiceSheets, setPracticeSheets] = useState([]);
const [notStartedSheets, setNotStartedSheets] = useState([]);
const [inProgressSheets, setInProgressSheets] = useState([]);
@@ -92,7 +90,7 @@ export const Home = () => {
};
return (
-
+
Welcome, {user?.name || "Student"}
@@ -103,7 +101,7 @@ export const Home = () => {
your scores now!
*/}
-
+ {/*
@@ -142,7 +140,7 @@ export const Home = () => {
-
+ */}
What are you looking for?
diff --git a/src/pages/student/Practice.tsx b/src/pages/student/Practice.tsx
index 732e201..ac5fcaf 100644
--- a/src/pages/student/Practice.tsx
+++ b/src/pages/student/Practice.tsx
@@ -24,6 +24,7 @@ export const Practice = () => {
const navigate = useNavigate();
const userXp = useExamConfigStore.getState().userXp;
+ console.log(userXp);
return (
diff --git a/src/pages/student/Rewards.tsx b/src/pages/student/Rewards.tsx
index 5ea3366..3cd200e 100644
--- a/src/pages/student/Rewards.tsx
+++ b/src/pages/student/Rewards.tsx
@@ -67,6 +67,7 @@ export const Rewards = () => {
const response = await api.fetchLeaderboard(token);
setLeaderboard(response);
+
setUserXp(response.user_rank.total_xp);
setLoading(false);
} catch (error) {
@@ -320,13 +321,15 @@ export const Rewards = () => {
{isTopThree ? (

) : (
- {leaderboard?.user_rank?.rank ?? Infinity}
+ {(leaderboard?.user_rank?.rank ?? Infinity) - 1}
)}
diff --git a/src/pages/student/practice/Pretest.tsx b/src/pages/student/practice/Pretest.tsx
index d28a07d..50a5936 100644
--- a/src/pages/student/practice/Pretest.tsx
+++ b/src/pages/student/practice/Pretest.tsx
@@ -13,11 +13,11 @@ import {
import { Button } from "../../../components/ui/button";
import { useNavigate } from "react-router-dom";
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
+import { useSatExam } from "../../../stores/useSatExam";
export const Pretest = () => {
const { setSheetId, setMode, storeDuration, setQuestionCount } =
useExamConfigStore();
-
const user = useAuthStore((state) => state.user);
const { sheetId } = useParams<{ sheetId: string }>();
const [carouselApi, setCarouselApi] = useState();
diff --git a/src/pages/student/practice/Results.tsx b/src/pages/student/practice/Results.tsx
index 915fc97..8abf690 100644
--- a/src/pages/student/practice/Results.tsx
+++ b/src/pages/student/practice/Results.tsx
@@ -1,12 +1,159 @@
import { useNavigate } from "react-router-dom";
import { Button } from "../../../components/ui/button";
+import { useResults } from "../../../stores/useResults";
+import { useSatExam } from "../../../stores/useSatExam";
+import { LucideArrowLeft } from "lucide-react";
+import {
+ Card,
+ CardAction,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "../../../components/ui/card";
+import { Progress } from "../../../components/ui/progress";
+import { CircularLevelProgress } from "../../../components/CircularLevelProgress";
+import { useEffect, useState } from "react";
+import { useExamConfigStore } from "../../../stores/useExamConfigStore";
+
+const XPGainedCard = ({
+ results,
+}: {
+ results?: {
+ xp_gained: number;
+ total_xp: number;
+ current_level_start: number;
+ next_level_threshold: number;
+ current_level: number;
+ };
+}) => {
+ const [displayXP, setDisplayXP] = useState(0);
+
+ useEffect(() => {
+ if (!results?.xp_gained) return;
+
+ let startTime: number | null = null;
+ const duration = 800; // ms
+
+ const animate = (time: number) => {
+ if (!startTime) startTime = time;
+ const t = Math.min((time - startTime) / duration, 1);
+ setDisplayXP(Math.floor(t * results.xp_gained));
+ if (t < 1) requestAnimationFrame(animate);
+ };
+
+ requestAnimationFrame(animate);
+ }, [results?.xp_gained]);
+
+ return (
+
+
+ XP
+ How much did you improve?
+
+ +{displayXP} XP
+
+
+
+ );
+};
export const Results = () => {
const navigate = useNavigate();
+ const results = useResults((s) => s.results);
+ const clearResults = useResults((s) => s.clearResults);
+
+ const { setUserXp } = useExamConfigStore();
+
+ useEffect(() => {
+ if (results) setUserXp(results?.total_xp);
+ }, [results]);
+
+ function handleFinishExam() {
+ clearResults();
+ navigate(`/student/home`);
+ }
+
+ // const [displayXP, setDisplayXP] = useState(0);
+
+ // useEffect(() => {
+ // if (!results?.score) return;
+ // let start = 0;
+ // const duration = 600;
+ // const startTime = performance.now();
+
+ // const animate = (time: number) => {
+ // const t = Math.min((time - startTime) / duration, 1);
+ // setDisplayXP(Math.floor(t * results.score));
+ // if (t < 1) requestAnimationFrame(animate);
+ // };
+
+ // requestAnimationFrame(animate);
+ // }, [results?.score]);
+
+ const previousXP = results ? results.total_xp - results.xp_gained : 0;
+
return (
-
- Your results go here
-
-
+
+
+
+ Results
+
+
+
+
+
+
+ Score
+ Total score you achieved.
+
+ {results?.score}
+
+
+
+
+
+ Accuracy
+ How many did you answer correct?
+
+
+ {results && results.total_questions > 0
+ ? `${Math.round(
+ (results.correct_count / results.total_questions) * 100,
+ )}%`
+ : "—"}
+
+
+
+
+
+
+ How do you improve?
+
+ Your score is good, but you can do better!
+
+
+
+
);
};
diff --git a/src/pages/student/practice/Test.tsx b/src/pages/student/practice/Test.tsx
index d220802..d963987 100644
--- a/src/pages/student/practice/Test.tsx
+++ b/src/pages/student/practice/Test.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
-import { useNavigate } from "react-router-dom";
+import { Navigate, useNavigate } from "react-router-dom";
import {
Card,
CardContent,
@@ -39,6 +39,7 @@ import {
} from "../../../components/ui/dialog";
import { useExamNavigationGuard } from "../../../hooks/useExamNavGuard";
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
+import { useResults } from "../../../stores/useResults";
export const Test = () => {
const sheetId = localStorage.getItem("activePracticeSheetId");
@@ -79,6 +80,8 @@ export const Test = () => {
const goToQuestion = useSatExam((s) => s.goToQuestion);
const finishExam = useSatExam((s) => s.finishExam);
+ const quitExam = useSatExam((s) => s.quitExam);
+ const setResults = useResults((s) => s.setResults);
const startExam = async () => {
if (!user || !sheetId) return;
@@ -181,6 +184,11 @@ export const Test = () => {
if (next?.status === "COMPLETED") {
useExamConfigStore.getState().clearPayload();
+ console.log(next.results);
+ setResults(next.results);
+
+ // ✅ Store results first
+
finishExam();
} else {
await loadSessionQuestions(sessionId);
@@ -190,14 +198,20 @@ export const Test = () => {
const handleQuitExam = () => {
useExamConfigStore.getState().clearPayload();
- finishExam();
- navigate("/student/home");
+
+ quitExam();
};
useEffect(() => {
resetExam(); // ✅ important
}, [sheetId]);
+ useEffect(() => {
+ return () => {
+ resetExam();
+ };
+ }, []);
+
useEffect(() => {
if (phase === "FINISHED") {
const timer = setTimeout(() => {
@@ -402,7 +416,7 @@ export const Test = () => {
{currentQuestion?.context && (
- {currentQuestion?.context}
+ {renderQuestionText(currentQuestion?.context)}
)}
@@ -611,6 +625,8 @@ export const Test = () => {
Redirecting to results...
);
+ case "QUIT":
+ return ;
default:
return null;
diff --git a/src/pages/student/targeted-practice/page.tsx b/src/pages/student/targeted-practice/page.tsx
index da56d5a..fd8aa27 100644
--- a/src/pages/student/targeted-practice/page.tsx
+++ b/src/pages/student/targeted-practice/page.tsx
@@ -17,8 +17,8 @@ export const TargetedPractice = () => {
const navigate = useNavigate();
const {
storeTopics,
- setDifficulty: storeDifficulty,
storeDuration,
+ setDifficulty: storeDifficulty,
setMode,
setQuestionCount,
} = useExamConfigStore();
@@ -34,7 +34,6 @@ export const TargetedPractice = () => {
const [difficulty, setDifficulty] = useState<
"EASY" | "MEDIUM" | "HARD" | null
>(null);
- const [duration, setDuration] = useState(null);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(false);
@@ -43,8 +42,6 @@ export const TargetedPractice = () => {
const difficulties = ["EASY", "MEDIUM", "HARD"] as const;
- const durations = [10, 20, 30, 45];
-
const toggleTopic = (topic: Topic) => {
setSelectedTopics((prev) => {
const exists = prev.some((t) => t.id === topic.id);
@@ -58,7 +55,9 @@ export const TargetedPractice = () => {
};
async function handleStartTargetedPractice() {
- if (!user || !token || !topics || !difficulty || !duration) return;
+ if (!user || !token || !topics || !difficulty) return;
+
+ storeDuration(10);
navigate(`/student/practice/${topics[0].id}/test`, { replace: true });
}
@@ -193,36 +192,6 @@ export const TargetedPractice = () => {
setDifficulty(d); // local UI
storeDifficulty(d); // ✅ STORE
setDirection(1);
- setStep("duration");
- }}
- />
- ))}
-
-
- )}
-
- {step === "duration" && (
-
- Select duration
-
-
- {durations.map((d) => (
-
{
- setDuration(d);
- storeDuration(d); // ✅ STORE
- setDirection(1);
setStep("review");
}}
/>
@@ -252,9 +221,6 @@ export const TargetedPractice = () => {
Difficulty: {difficulty}
-
- Duration: {duration} minutes
-
)}
@@ -263,7 +229,7 @@ export const TargetedPractice = () => {