fix(ui): change ui theme color

feat(calc): add geogebra based graph calculator for tests
This commit is contained in:
shafin-r
2026-02-20 00:03:23 +06:00
parent 626616c8b5
commit 3c8f945539
18 changed files with 2063 additions and 259 deletions

View File

@ -0,0 +1,305 @@
import { useEffect, useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../components/ui/card";
import { api } from "../utils/api";
import { useAuthToken } from "../hooks/useAuthToken";
import {
TrendingUp,
BookOpen,
Calculator,
Loader2,
ChevronDown,
ChevronUp,
} from "lucide-react";
// ─── Types ────────────────────────────────────────────────────────────────────
interface SectionPrediction {
score: number;
range_min: number;
range_max: number;
confidence: string;
}
interface PredictedScoreResponse {
total_score: number;
math_prediction: SectionPrediction;
rw_prediction: SectionPrediction;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
const confidenceConfig: Record<
string,
{ label: string; color: string; bg: string; dot: string }
> = {
high: {
label: "High confidence",
color: "text-emerald-700",
bg: "bg-emerald-50 border-emerald-200",
dot: "bg-emerald-500",
},
medium: {
label: "Medium confidence",
color: "text-amber-700",
bg: "bg-amber-50 border-amber-200",
dot: "bg-amber-400",
},
low: {
label: "Low confidence",
color: "text-rose-700",
bg: "bg-rose-50 border-rose-200",
dot: "bg-rose-400",
},
};
const getConfidenceStyle = (confidence: string) =>
confidenceConfig[confidence.toLowerCase()] ?? {
label: confidence,
color: "text-gray-600",
bg: "bg-gray-50 border-gray-200",
dot: "bg-gray-400",
};
const useCountUp = (target: number, duration = 900) => {
const [value, setValue] = useState(0);
useEffect(() => {
if (!target) return;
let start: number | null = null;
const step = (ts: number) => {
if (!start) start = ts;
const progress = Math.min((ts - start) / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
setValue(Math.floor(eased * target));
if (progress < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}, [target, duration]);
return value;
};
// ─── Expanded section detail ──────────────────────────────────────────────────
const SectionDetail = ({
label,
icon: Icon,
prediction,
accentClass,
}: {
label: string;
icon: React.ElementType;
prediction: SectionPrediction;
accentClass: string;
}) => {
const conf = getConfidenceStyle(prediction.confidence);
return (
<div className="flex flex-col gap-2 rounded-2xl border border-gray-100 bg-gray-50 px-4 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`p-1.5 rounded-lg ${accentClass}`}>
<Icon size={14} className="text-white" />
</div>
<span className="font-satoshi-medium text-sm text-gray-700">
{label}
</span>
</div>
<span
className={`flex items-center gap-1.5 text-xs px-2 py-0.5 rounded-full border font-satoshi ${conf.bg} ${conf.color}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${conf.dot}`} />
{conf.label}
</span>
</div>
<div className="flex items-end justify-between mt-1">
<span className="font-satoshi-bold text-2xl text-gray-900">
{prediction.score}
</span>
<span className="font-satoshi text-xs text-gray-400 mb-1">
Range:{" "}
<span className="text-gray-600 font-satoshi-medium">
{prediction.range_min}{prediction.range_max}
</span>
</span>
</div>
{/* Range bar */}
<div className="relative h-1.5 rounded-full bg-gray-200 mt-1">
<div
className={`absolute h-1.5 rounded-full ${accentClass} opacity-60`}
style={{
left: `${((prediction.range_min - 200) / (800 - 200)) * 100}%`,
right: `${100 - ((prediction.range_max - 200) / (800 - 200)) * 100}%`,
}}
/>
<div
className={`absolute w-2.5 h-2.5 rounded-full border-2 border-white ${accentClass} -top-0.5 shadow-sm`}
style={{
left: `calc(${((prediction.score - 200) / (800 - 200)) * 100}% - 5px)`,
}}
/>
</div>
<div className="flex justify-between text-[10px] text-gray-300 font-satoshi mt-0.5">
<span>200</span>
<span>800</span>
</div>
</div>
);
};
// ─── Main component ───────────────────────────────────────────────────────────
export const PredictedScoreCard = () => {
const token = useAuthToken();
const [data, setData] = useState<PredictedScoreResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
if (!token) return;
(async () => {
try {
setLoading(true);
const result = await api.fetchPredictedScore(token);
setData(result);
} catch (err) {
setError("Couldn't load your predicted score.");
console.error(err);
} finally {
setLoading(false);
}
})();
}, [token]);
const animatedTotal = useCountUp(data?.total_score ?? 0, 1000);
return (
<Card className="w-full border border-gray-200 shadow-sm overflow-hidden">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="font-satoshi-bold text-lg text-gray-900">
Predicted SAT Score
</CardTitle>
<CardDescription className="font-satoshi text-sm text-gray-400 mt-0.5">
Based on your practice performance
</CardDescription>
</div>
<div className="p-2 rounded-xl bg-purple-50 border border-purple-100">
<TrendingUp size={18} className="text-purple-500" />
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{loading && (
<div className="flex items-center justify-center py-8">
<Loader2 size={26} className="animate-spin text-purple-400" />
</div>
)}
{error && !loading && (
<p className="font-satoshi text-sm text-rose-500 text-center py-4">
{error}
</p>
)}
{data && !loading && (
<>
{/* ── Collapsed view: big numbers only ── */}
<div className="flex items-center justify-between">
{/* Total */}
<div className="flex flex-col">
<span className="font-satoshi text-lg text-gray-400 mb-0.5">
Total
</span>
<span className="font-satoshi-bold text-6xl text-gray-900 leading-none">
{animatedTotal}
</span>
<span className="font-satoshi text-[18px] text-gray-300 mt-1">
out of 1600
</span>
</div>
<div className="h-12 w-px bg-gray-100" />
{/* Math */}
<div className="flex flex-col items-center">
<div className="flex items-center gap-1 mb-0.5">
<Calculator size={16} className="text-violet-400" />
<span className="font-satoshi text-sm text-gray-400">
Math
</span>
</div>
<span className="font-satoshi-bold text-3xl text-gray-900 leading-none">
{data.math_prediction.score}
</span>
<span className="font-satoshi text-[12px] text-gray-300 mt-1">
out of 800
</span>
</div>
<div className="h-12 w-px bg-gray-100" />
{/* R&W */}
<div className="flex flex-col items-center">
<div className="flex items-center gap-1 mb-0.5">
<BookOpen size={16} className="text-sky-400" />
<span className="font-satoshi text-sm text-gray-400">
R&W
</span>
</div>
<span className="font-satoshi-bold text-3xl text-gray-900 leading-none">
{data.rw_prediction.score}
</span>
<span className="font-satoshi text-[12px] text-gray-300 mt-1">
out of 800
</span>
</div>
</div>
{/* ── Expand toggle ── */}
<button
onClick={() => setExpanded((p) => !p)}
className="w-full flex items-center justify-center gap-1.5 py-2 text-xs font-satoshi-medium text-gray-400 hover:text-purple-500 transition-colors"
>
{expanded ? (
<>
<ChevronUp size={14} /> Less detail
</>
) : (
<>
<ChevronDown size={14} /> More detail
</>
)}
</button>
{/* ── Expanded: range bars + confidence ── */}
{expanded && (
<div className="space-y-3 pt-1">
<SectionDetail
label="Math"
icon={Calculator}
prediction={data.math_prediction}
accentClass="bg-violet-500"
/>
<SectionDetail
label="Reading & Writing"
icon={BookOpen}
prediction={data.rw_prediction}
accentClass="bg-sky-500"
/>
</div>
)}
</>
)}
</CardContent>
</Card>
);
};