perform test #1

test to courses

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-24 12:49:20 +08:00
parent fbef7cb6e6
commit 93c3c688ca
26 changed files with 2141 additions and 452 deletions
@@ -1,12 +1,14 @@
// components/QuizBlock.jsx
import { useState, useEffect } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
ChevronLeft, ChevronRight,
Circle, CheckCircle2,
Square, CheckSquare2,
Clock, AlertTriangle, Info,
} from "lucide-react";
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
function QuizSkeleton() {
return (
@@ -24,30 +26,225 @@ function QuizSkeleton() {
);
}
function formatTime(secs) {
if (secs === null || secs === undefined) return null;
const m = Math.floor(secs / 60).toString().padStart(2, "0");
const s = (secs % 60).toString().padStart(2, "0");
return `${m}:${s}`;
}
/**
* Props:
* quiz — { quiz_id, title, is_required, passing_score, max_questions, attempt_count, has_passed, best_attempt, questions: [...] }
* quiz — assessment data including active_session, time_limit_minutes, etc.
* loading — true while fetch is in-flight
* onSubmit — (answers) => Promise<result|null>
* label — noun used in copy ("Quiz" or "Assessment"), default "Quiz"
* onStart — async () => { session_id, expires_at, remaining_seconds, draft_answers } — only for timed assessments
* onDraft — (answers) => void — called every 25s to UPSERT draft + last_heartbeat_at
* onRefreshSession — async () => { expires_at, remaining_seconds } — called when assessment_updated fires
* onSubmit — async (answers, sessionId?) => result|null
* onRetake — () => void — refetch so attempt stats refresh
* label — "Quiz" or "Assessment"
*/
const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }) => {
const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession, onSubmit, onRetake, label = "Quiz" }) => {
const questions = quiz?.questions ?? [];
const total = questions.length;
const [stage, setStage] = useState("intro"); // 'intro' | 'taking' | 'result'
// ── Core stage state ──────────────────────────────────────────────────────
const [stage, setStage] = useState("intro"); // 'intro' | 'taking' | 'result'
const [currentIndex, setCurrentIndex] = useState(0);
const [answers, setAnswers] = useState({});
const [answers, setAnswers] = useState({});
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState(null);
const [starting, setStarting] = useState(false);
const [result, setResult] = useState(null);
// ── Timer state ───────────────────────────────────────────────────────────
const [remainingSeconds, setRemainingSeconds] = useState(null); // null = no active countdown
const [timeExpired, setTimeExpired] = useState(false);
// ── Notifications (assessment_updated alert) ──────────────────────────────
const { notifications, fetchNotifications, accelerate, decelerate } = useClientNotifications();
const [assessmentUpdatedAlert, setAssessmentUpdatedAlert] = useState(false);
const seenNotifRef = useRef(new Set()); // tracks notification_ids already shown
// ── Session refs (stable across renders, no stale-closure issues) ─────────
const sessionRef = useRef({ sessionId: null, expiresAt: null });
const answersRef = useRef({});
const timerFiredRef = useRef(false);
// Keep answersRef in sync with state so the timer's auto-submit closure reads fresh data
useEffect(() => { answersRef.current = answers; }, [answers]);
// Reset all state when the quiz/assessment changes
useEffect(() => {
setStage("intro");
setCurrentIndex(0);
setAnswers({});
setResult(null);
setRemainingSeconds(null);
setTimeExpired(false);
sessionRef.current = { sessionId: null, expiresAt: null };
timerFiredRef.current = false;
// Pre-load any saved draft so the student can see their progress on the intro screen
const draft = quiz?.active_session?.draft_answers ?? {};
setAnswers(Object.keys(draft).length > 0 ? draft : {});
answersRef.current = draft;
}, [quiz?.quiz_id]);
// ── Speed up notification polling while mid-assessment ───────────────────
useEffect(() => {
if (stage !== "taking") { decelerate(); return; }
accelerate();
fetchNotifications(); // immediate refresh when stage starts
return () => decelerate();
}, [stage]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Watch for assessment_updated notifications ────────────────────────────
useEffect(() => {
if (stage !== "taking") return;
notifications.forEach((n) => {
if (n.type === "assessment" && n.title === "Assessment Updated" && !seenNotifRef.current.has(n.notification_id)) {
seenNotifRef.current.add(n.notification_id);
setAssessmentUpdatedAlert(true);
// Refresh the session timer — admin may have changed time_limit_minutes
if (onRefreshSession) {
onRefreshSession().then(updated => {
if (updated?.remaining_seconds != null) {
setRemainingSeconds(updated.remaining_seconds);
sessionRef.current.expiresAt = updated.expires_at
? new Date(updated.expires_at)
: null;
}
});
}
}
});
}, [notifications, stage]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Draft auto-save: immediate on enter, then every 25s ──────────────────
// The immediate call sets last_heartbeat_at right away so the crash-resume
// freeze logic always has a reference point even if the tab closes within seconds.
useEffect(() => {
if (stage !== "taking" || !onDraft) return;
onDraft(answersRef.current); // immediate — seeds last_heartbeat_at now
const id = setInterval(() => onDraft(answersRef.current), 25_000);
return () => clearInterval(id);
}, [stage, onDraft]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Timer tick (only active while stage === 'taking' and expiresAt is set) ─
useEffect(() => {
if (stage !== "taking" || !sessionRef.current.expiresAt) return;
timerFiredRef.current = false;
const tick = () => {
const secs = Math.max(0, Math.ceil((sessionRef.current.expiresAt.getTime() - Date.now()) / 1000));
setRemainingSeconds(secs);
if (secs <= 0 && !timerFiredRef.current) {
timerFiredRef.current = true;
setTimeExpired(true);
clearInterval(id);
}
};
tick(); // immediate first tick so UI shows correct time right away
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [stage]); // intentionally only stage — expiresAt is a ref
// ── Auto-submit when time runs out ────────────────────────────────────────
useEffect(() => {
if (!timeExpired || submitting) return;
const autoSubmit = async () => {
setSubmitting(true);
const res = await onSubmit?.(answersRef.current, sessionRef.current.sessionId);
setSubmitting(false);
if (res) {
setResult(res);
setStage("result");
}
};
autoSubmit();
}, [timeExpired]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Handlers ─────────────────────────────────────────────────────────────
const handleStart = useCallback(async (resuming = false) => {
const isAssessment = label === "Assessment";
const hasTimeLimit = isAssessment && (quiz?.time_limit_minutes ?? 0) > 0;
// Always create/resume a session for assessments (timed or not) so admin
// can see in-progress records and draft answers are preserved on crash.
if (isAssessment && onStart) {
setStarting(true);
const session = await onStart();
setStarting(false);
if (!session) return; // onStart already toasted the error
sessionRef.current = {
sessionId: session.session_id,
expiresAt: session.expires_at ? new Date(session.expires_at) : null,
};
if (hasTimeLimit && session.remaining_seconds != null) {
setRemainingSeconds(session.remaining_seconds);
}
// Restore draft answers saved before the crash/close
if (session.draft_answers && Object.keys(session.draft_answers).length > 0) {
setAnswers(session.draft_answers);
answersRef.current = session.draft_answers;
}
}
setStage("taking");
}, [label, quiz?.time_limit_minutes, onStart]);
const handleRetake = () => {
setCurrentIndex(0);
setAnswers({});
setResult(null);
setRemainingSeconds(null);
setTimeExpired(false);
sessionRef.current = { sessionId: null, expiresAt: null };
timerFiredRef.current = false;
setStage("intro");
onRetake?.();
};
const handleOptionClick = (optionId) => {
const question = questions[currentIndex];
const isMulti = question.type === "multi_select";
const multiLimit = isMulti ? (question.correct_count ?? null) : null;
setAnswers((prev) => {
if (!isMulti) return { ...prev, [question.question_id]: optionId };
const current = prev[question.question_id] ?? [];
if (!current.includes(optionId) && multiLimit !== null && current.length >= multiLimit) return prev;
const next = current.includes(optionId)
? current.filter((id) => id !== optionId)
: [...current, optionId];
return { ...prev, [question.question_id]: next };
});
};
const handleNext = async () => {
if (isLast) {
setSubmitting(true);
const res = await onSubmit?.(answers, sessionRef.current.sessionId);
setSubmitting(false);
if (res) {
setResult(res);
setStage("result");
}
return;
}
setCurrentIndex((i) => Math.min(i + 1, total - 1));
};
const handlePrev = () => {
if (isFirst) { setStage("intro"); return; }
setCurrentIndex((i) => Math.max(i - 1, 0));
};
// ── Empty / loading guards ────────────────────────────────────────────────
if (!quiz && !loading) {
return (
<div className="h-full w-full flex items-center justify-center text-muted-foreground py-20">
@@ -66,20 +263,17 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
);
}
const handleRetake = () => {
setCurrentIndex(0);
setAnswers({});
setResult(null);
setStage("intro");
onRetake?.(); // refetch so attempts_remaining/cooldown_until reflect the submission that just happened
};
// ── Derived values ────────────────────────────────────────────────────────
const isAssessment = label === "Assessment";
const hasTimeLimit = isAssessment && (quiz?.time_limit_minutes ?? 0) > 0;
const activeSession = isAssessment ? (quiz?.active_session ?? null) : null; // pre-existing in_progress from server
// ── Intro screen ─────────────────────────────────────────────────────────
if (stage === "intro") {
const attempts = quiz.attempt_count ?? 0;
const attemptsRemaining = quiz.attempts_remaining ?? null; // null = backend hasn't sent this field yet
const cooldownUntil = quiz.cooldown_until ? new Date(quiz.cooldown_until) : null;
const canAttempt = quiz.can_attempt ?? true; // default open if field is absent, for back-compat
const attempts = quiz.attempt_count ?? 0;
const attemptsRemaining = quiz.attempts_remaining ?? null;
const cooldownUntil = quiz.cooldown_until ? new Date(quiz.cooldown_until) : null;
const canAttempt = quiz.can_attempt ?? true;
return (
<div className="max-w-2xl mx-auto">
@@ -91,6 +285,17 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
Required to complete this {label === "Assessment" ? "course" : "unit"}
</span>
)}
{label === "Assessment" && quiz.max_attempts && quiz.cooldown_hours && (
<span className="inline-block rounded-full bg-muted px-2.5 py-0.5 text-xs text-muted-foreground">
{quiz.max_attempts} failed attempt{quiz.max_attempts !== 1 ? "s" : ""} → {quiz.cooldown_hours}h cooldown
</span>
)}
{hasTimeLimit && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-2.5 py-0.5 text-xs font-medium text-blue-600 dark:text-blue-400">
<Clock className="size-3" />
{quiz.time_limit_minutes} minute time limit
</span>
)}
</div>
{quiz.has_passed && (
@@ -130,20 +335,39 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
</div>
)}
{/* Resume banner — shown when the server reports an active in_progress session */}
{activeSession && canAttempt && (
<div className="rounded-lg border border-blue-500/30 bg-blue-500/5 px-4 py-3 text-left space-y-1">
<p className="text-sm font-medium text-blue-700 dark:text-blue-400 flex items-center gap-1.5">
<Clock className="size-4" /> Session in progress
</p>
<p className="text-xs text-muted-foreground">
You have an unfinished attempt.{" "}
{activeSession.remaining_seconds != null
? `${formatTime(activeSession.remaining_seconds)} remaining — resume before time runs out.`
: "Resume where you left off."}
</p>
</div>
)}
<div className="flex items-center justify-center gap-6 sm:gap-10">
<div className="space-y-0.5">
<p className="text-2xl font-bold sm:text-3xl">{total}</p>
<p className="text-xs text-muted-foreground sm:text-sm">Question{total === 1 ? "" : "s"}</p>
</div>
<div className="h-10 w-px bg-border" />
<div className="space-y-0.5">
<p className="text-2xl font-bold sm:text-3xl">
{attemptsRemaining !== null ? attemptsRemaining : attempts}
</p>
<p className="text-xs text-muted-foreground sm:text-sm">
{attemptsRemaining !== null ? "Attempts Left" : `Attempt${attempts === 1 ? "" : "s"}`}
</p>
</div>
{(attemptsRemaining !== null || attempts > 0) && (
<>
<div className="h-10 w-px bg-border" />
<div className="space-y-0.5">
<p className="text-2xl font-bold sm:text-3xl">
{attemptsRemaining !== null ? attemptsRemaining : attempts}
</p>
<p className="text-xs text-muted-foreground sm:text-sm">
{attemptsRemaining !== null ? "Attempts Left" : `Attempt${attempts === 1 ? "" : "s"}`}
</p>
</div>
</>
)}
<div className="h-10 w-px bg-border" />
<div className="space-y-0.5">
<p className="text-2xl font-bold sm:text-3xl">{quiz.passing_score}%</p>
@@ -151,8 +375,19 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
</div>
</div>
<Button size="lg" className="w-full sm:w-auto" onClick={() => setStage("taking")} disabled={!canAttempt}>
{attempts > 0 ? `Retake ${label}` : `Start ${label}`}
<Button
size="lg"
className="w-full sm:w-auto"
onClick={() => handleStart(!!activeSession)}
disabled={!canAttempt || starting}
>
{starting
? "Starting…"
: activeSession
? "Resume Assessment"
: attempts > 0
? `Retake ${label}`
: `Start ${label}`}
</Button>
</div>
</div>
@@ -186,47 +421,57 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
}
// ── Question stepper ─────────────────────────────────────────────────────
const question = questions[currentIndex];
const isFirst = currentIndex === 0;
const isLast = currentIndex === total - 1;
const isMulti = question.type === "multi_select";
const selected = answers[question.question_id];
const progress = Math.round(((currentIndex + 1) / total) * 100);
const question = questions[currentIndex];
const isFirst = currentIndex === 0;
const isLast = currentIndex === total - 1;
const isMulti = question.type === "multi_select";
const selected = answers[question.question_id];
const progress = Math.round(((currentIndex + 1) / total) * 100);
const handleOptionClick = (optionId) => {
setAnswers((prev) => {
if (!isMulti) return { ...prev, [question.question_id]: optionId };
const current = prev[question.question_id] ?? [];
const next = current.includes(optionId)
? current.filter((id) => id !== optionId)
: [...current, optionId];
return { ...prev, [question.question_id]: next };
});
};
const multiLimit = isMulti ? (question.correct_count ?? null) : null;
const selectedCount = isMulti ? (selected ?? []).length : 0;
const limitReached = multiLimit !== null && selectedCount >= multiLimit;
const handleNext = async () => {
if (isLast) {
setSubmitting(true);
const res = await onSubmit?.(answers);
setSubmitting(false);
if (res) {
setResult(res);
setStage("result");
}
return;
}
setCurrentIndex((i) => Math.min(i + 1, total - 1));
};
const handlePrev = () => {
if (isFirst) { setStage("intro"); return; }
setCurrentIndex((i) => Math.max(i - 1, 0));
};
// Timer color: red < 60s, amber < 5min, default otherwise
const timerColor = remainingSeconds !== null
? remainingSeconds <= 60
? "text-red-500 dark:text-red-400"
: remainingSeconds <= 300
? "text-amber-500 dark:text-amber-400"
: "text-muted-foreground"
: null;
return (
<div className="max-w-2xl mx-auto space-y-5">
{/* ── Assessment updated alert banner ── */}
{assessmentUpdatedAlert && (
<div className="flex items-start gap-3 rounded-lg border border-blue-500/30 bg-blue-500/5 px-4 py-3">
<Info className="h-4 w-4 text-blue-500 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-blue-700 dark:text-blue-400">Assessment Updated</p>
<p className="text-xs text-blue-600/80 dark:text-blue-300/70 mt-0.5">
Your administrator has made changes to this assessment. Your current session and saved answers are unaffected — continue as normal.
</p>
</div>
<button
onClick={() => setAssessmentUpdatedAlert(false)}
className="text-blue-400 hover:text-blue-600 shrink-0 text-xs leading-none mt-0.5"
>✕</button>
</div>
)}
<div className="space-y-2">
{quiz.title && <h2 className="text-lg font-semibold sm:text-xl">{quiz.title}</h2>}
<div className="flex items-center justify-between">
{quiz.title && <h2 className="text-lg font-semibold sm:text-xl truncate">{quiz.title}</h2>}
{remainingSeconds !== null && (
<span className={`flex items-center gap-1 font-mono text-sm font-bold tabular-nums shrink-0 ml-3 ${timerColor}`}>
{timeExpired && submitting
? <><AlertTriangle className="size-3.5" /> Time's up</>
: <><Clock className="size-3.5" />{formatTime(remainingSeconds)}</>
}
</span>
)}
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground sm:text-sm">
<span>Question {currentIndex + 1} of {total}</span>
<span>{progress}%</span>
@@ -236,16 +481,32 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
</div>
</div>
{timeExpired && submitting && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-2 text-xs text-amber-700 dark:text-amber-400 text-center">
Time's up — submitting your answers…
</div>
)}
<div className="rounded-xl border bg-card p-4 space-y-4 sm:p-6">
<p className="font-bold text-base leading-relaxed sm:text-lg">
{currentIndex + 1}. {question.question}
</p>
<div className="space-y-1">
<p className="font-bold text-base leading-relaxed sm:text-lg">
{currentIndex + 1}. {question.question}
</p>
{isMulti && multiLimit !== null && (
<p className={`text-xs font-medium ${limitReached ? "text-amber-600 dark:text-amber-400" : "text-blue-600 dark:text-blue-400"}`}>
{limitReached
? `${selectedCount} / ${multiLimit} selected — limit reached`
: `Select ${multiLimit} answer${multiLimit !== 1 ? "s" : ""} · ${selectedCount} / ${multiLimit} selected`}
</p>
)}
</div>
<div className="space-y-2">
{(question.options ?? []).map((option, i) => {
const letter = String.fromCharCode(65 + i);
const isSelected = isMulti
? (selected ?? []).includes(option.option_id)
: selected === option.option_id;
const isDisabled = (isMulti && limitReached && !isSelected) || (timeExpired && submitting);
const Icon = isMulti
? (isSelected ? CheckSquare2 : Square)
: (isSelected ? CheckCircle2 : Circle);
@@ -255,8 +516,10 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
key={option.option_id}
type="button"
onClick={() => handleOptionClick(option.option_id)}
className={`flex w-full items-center gap-3 rounded-lg border px-3 py-2.5 text-left text-sm transition-colors sm:text-base ${isSelected ? "border-primary bg-primary/5" : "border-border hover:bg-muted-foreground/5"
}`}
disabled={isDisabled}
className={`flex w-full items-center gap-3 rounded-lg border px-3 py-2.5 text-left text-sm transition-colors sm:text-base
${isSelected ? "border-primary bg-primary/5" : "border-border"}
${isDisabled ? "opacity-40 cursor-not-allowed" : "hover:bg-muted-foreground/5"}`}
>
<Icon className={`size-4 shrink-0 ${isSelected ? "text-primary" : "text-muted-foreground"}`} />
<span className="font-medium text-muted-foreground">{letter}.</span>
@@ -273,7 +536,7 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
Previous
</Button>
<Button onClick={handleNext} disabled={submitting}>
{submitting ? "Submitting..." : isLast ? "Submit" : "Next"}
{submitting ? "Submitting…" : isLast ? "Submit" : "Next"}
{!isLast && !submitting && <ChevronRight className="size-4" />}
</Button>
</div>
@@ -281,4 +544,4 @@ const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }
);
};
export default QuizBlock;
export default QuizBlock;