feat(results): add resutls page

fix(leaderboard): fix leaderboard fetch logic

fix(test): fix navigation bug upon test quit
This commit is contained in:
shafin-r
2026-02-10 19:32:46 +06:00
parent 8cfcb11f0a
commit 7f82e640e0
17 changed files with 560 additions and 82 deletions

View File

@ -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 (
<div className="relative flex flex-col items-center gap-2">
{showLevelUp && <ConfettiBurst />}
<div
className="relative flex items-center justify-center"
style={{ width: size, height: size }}
>
<svg width={size} height={size}>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="oklch(94.6% 0.033 307.174)"
strokeWidth={strokeWidth}
fill="none"
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="oklch(62.7% 0.265 303.9)"
strokeWidth={strokeWidth}
fill="none"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
</svg>
<span className="absolute text-[100px] font-satoshi-bold flex flex-col items-center">
{currentLevel}
{showThresholdText && (
<span className="text-xl font-satoshi-medium text-gray-500 animate-fade-in">
Total XP: {previousXP + gainedXP}
</span>
)}
{showLevelUp && (
<span className="text-xl font-satoshi-medium text-purple-600 animate-fade-in">
🎉 You leveled up!
</span>
)}
</span>
</div>
</div>
);
};