diff --git a/app/[locale]/dashboard/student/courses/[courseId]/exercises/[exerciseId]/page.tsx b/app/[locale]/dashboard/student/courses/[courseId]/exercises/[exerciseId]/page.tsx index a6e609549..7d7fe654c 100644 --- a/app/[locale]/dashboard/student/courses/[courseId]/exercises/[exerciseId]/page.tsx +++ b/app/[locale]/dashboard/student/courses/[courseId]/exercises/[exerciseId]/page.tsx @@ -5,6 +5,8 @@ import { requireCourseAccess, requireRowInCourse } from '@/lib/services/course-a import { getTranslations } from 'next-intl/server' import { EXTERNAL_EXERCISE_TYPES } from '@/lib/checkpoints/types' import { getCheckpointLinkedExerciseIds } from '@/lib/checkpoints/load' +import { toLatestEvaluation, type LatestExerciseEvaluation } from '@/lib/exercises/latest-evaluation' +import ExerciseResultSummary from '@/components/exercises/exercise-result-summary' import type { SpeechEvaluation } from '@/lib/speech/types' import dynamic from 'next/dynamic' @@ -171,8 +173,12 @@ export default async function ExercisePage({ params }: PageProps) { // Fetch evaluation history from unified exercise_evaluations table let submissionHistory: { id: number; ai_evaluation: SpeechEvaluation | null; score: number | null; status: string; media_url: string; created_at: string; duration_seconds: number | null }[] = [] + // Newest graded attempt, replayed to the student when they come back to a + // finished exercise. Derived from the same rows as submissionHistory — the + // artifact/essay engines write here too, their results were just never read. + let latestEvaluation: LatestExerciseEvaluation | null = null try { - const { data: evaluations } = await supabase + const { data: evaluations, error: evaluationsError } = await supabase .from('exercise_evaluations') .select('id, score, passed, ai_result, ai_metrics, engine_type, attempt_number, created_at') .eq('exercise_id', parseInt(exerciseId)) @@ -180,6 +186,10 @@ export default async function ExercisePage({ params }: PageProps) { .eq('tenant_id', tenantId) .order('created_at', { ascending: false }) + // PostgREST reports failures in `error`, not by throwing, so without this + // the catch below never fires and a broken query looks like "no attempts". + if (evaluationsError) console.error('Error fetching exercise evaluations:', evaluationsError) + // Map to legacy format for AudioExercise component compatibility submissionHistory = (evaluations ?? []).map(ev => ({ id: Number(ev.id), @@ -192,8 +202,11 @@ export default async function ExercisePage({ params }: PageProps) { (ev.ai_metrics as unknown as { duration_seconds?: number | null } | null) ?.duration_seconds ?? null, })) - } catch { + + latestEvaluation = toLatestEvaluation(evaluations?.[0] ?? null) + } catch (err) { // RLS or table access may fail — gracefully degrade + console.error('Error fetching exercise evaluations:', err) } const exerciseConfig = exercise.exercise_config as unknown as { @@ -267,6 +280,33 @@ export default async function ExercisePage({ params }: PageProps) { ) : null + // Code challenges are graded by their own test runner and never write an + // exercise_evaluations row, so their reviewable record is the completion. + const codeCompletion = exercise.exercise_completions?.[0] as + | { score?: number | null; completed_at?: string | null } + | undefined + + const resultSummary = latestEvaluation ? ( + + ) : null + + const codeResultSummary = codeCompletion ? ( + + ) : null + const chatComponent = ( ) : exercise.exercise_type === 'audio_evaluation' ? ( {chatComponent} diff --git a/components/exercises/artifact-exercise.tsx b/components/exercises/artifact-exercise.tsx index a2a30110f..0479b2caf 100644 --- a/components/exercises/artifact-exercise.tsx +++ b/components/exercises/artifact-exercise.tsx @@ -3,6 +3,7 @@ import { useState, useRef, useCallback, useEffect } from 'react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import ExerciseResultSummary from '@/components/exercises/exercise-result-summary' import { cn } from '@/lib/utils' import Markdown from 'react-markdown' import { useTranslations } from 'next-intl' @@ -16,7 +17,6 @@ import { IconSparkles, IconLoader2, IconAlertTriangle, - IconTarget, IconArrowRight, IconTrophy, IconCode, @@ -40,6 +40,9 @@ interface ArtifactExerciseProps { isExerciseCompleted: boolean passingScore: number isExerciseCompletedSection?: React.ReactNode + /** Last graded attempt from exercise_evaluations. The route has always written + * one; it was simply never read back, so reloading the page lost the feedback. */ + initialEvaluation?: EvaluationResult | null } interface EvaluationResult { @@ -58,6 +61,7 @@ export default function ArtifactExercise({ isExerciseCompleted, passingScore, isExerciseCompletedSection, + initialEvaluation = null, }: ArtifactExerciseProps) { const t = useTranslations('exercises.artifact') const tAudio = useTranslations('exercises.audio') @@ -68,7 +72,7 @@ export default function ArtifactExercise({ const iframeRef = useRef(null) const [submitState, setSubmitState] = useState('idle') - const [evaluation, setEvaluation] = useState(null) + const [evaluation, setEvaluation] = useState(initialEvaluation) const [passed, setPassed] = useState(isExerciseCompleted) const [errorMsg, setErrorMsg] = useState(null) const [rateLimited, setRateLimited] = useState(false) @@ -303,98 +307,32 @@ export default function ArtifactExercise({ )} - {/* Evaluation result */} - {evaluation && submitState === 'done' && ( -
-

- - {tAudio('aiFeedback')} -

- - {/* Score */} -
-
-
- - {evaluation.score} - - / - {evaluation.passingScore} -
-
- - {evaluation.passed ? t('passed') : t('failed')} - -
- - {/* Feedback */} -

{evaluation.feedback}

- - {/* Strengths */} - {evaluation.strengths.length > 0 && ( -
-

- {t('strengths')} -

-
    - {evaluation.strengths.map((s, i) => ( -
  • - + - {s} -
  • - ))} -
-
- )} - - {/* Improvements */} - {evaluation.improvements.length > 0 && ( -
-

- {t('improvements')} -

-
    - {evaluation.improvements.map((imp, i) => ( -
  • - - - {imp} -
  • - ))} -
-
- )} + {/* Evaluation result — also shown for a restored prior attempt, whose + submitState is still 'idle' because nothing was submitted this visit. */} + {/* One result card across every engine. This block used to be a + near-copy of ExerciseResultSummary that printed the score over + the PASS MARK, twice: "62 / 70" reads as 89% when 62 is a fail. */} + {evaluation && submitState !== 'evaluating' && ( +
+ - {/* Try again */} {!evaluation.passed && !rateLimited && ( -
-
-
- - {evaluation.score} - - / - {evaluation.passingScore} -
-
- -
-
- -
+ )}
)} diff --git a/components/exercises/code-exercise.tsx b/components/exercises/code-exercise.tsx index c71d99d8d..e916c2410 100644 --- a/components/exercises/code-exercise.tsx +++ b/components/exercises/code-exercise.tsx @@ -12,12 +12,15 @@ interface CodeExerciseProps { studentId: string; courseId: string; children: ReactNode; + /** Prior completion record, shown when the student returns to a solved challenge. */ + resultSummary?: ReactNode; } export default function CodeExercise({ exercise, isExerciseCompleted, children, + resultSummary, }: CodeExerciseProps) { return (
@@ -42,6 +45,8 @@ export default function CodeExercise({
+ {resultSummary} + diff --git a/components/exercises/essay-exercise.tsx b/components/exercises/essay-exercise.tsx index 46d948ec8..c59337c6a 100644 --- a/components/exercises/essay-exercise.tsx +++ b/components/exercises/essay-exercise.tsx @@ -16,6 +16,8 @@ interface EssayExerciseProps { studentId: string; children: ReactNode; isExerciseCompletedSection?: ReactNode; + /** Last graded attempt, shown above the chat when the student returns. */ + resultSummary?: ReactNode; } const difficultyConfig: Record = { @@ -40,6 +42,7 @@ export default function EssayExercise({ isExerciseCompleted, children, isExerciseCompletedSection, + resultSummary, }: EssayExerciseProps) { const difficulty = difficultyConfig[exercise.difficulty_level] || difficultyConfig.easy; const DifficultyIcon = difficulty.icon; @@ -94,6 +97,14 @@ export default function EssayExercise({ transition={{ duration: 0.35, delay: 0.1 }} className="grid grid-cols-1 lg:grid-cols-12 gap-4 sm:gap-6" > + {/* Last graded attempt — first for a returning student, and + deliberately OUTSIDE the sticky chat column: adding it there + pushed that column past the viewport height, and `sticky` + stops pinning once the element is taller than its scrollport. */} + {resultSummary && ( +
{resultSummary}
+ )} + {/* Instructions */}
diff --git a/components/exercises/exercise-result-summary.tsx b/components/exercises/exercise-result-summary.tsx new file mode 100644 index 000000000..8674ddd3a --- /dev/null +++ b/components/exercises/exercise-result-summary.tsx @@ -0,0 +1,180 @@ +'use client' + +import { useTranslations, useLocale } from 'next-intl' +import { IconArrowNarrowRight, IconCheck, IconSparkles, IconTarget } from '@tabler/icons-react' +import { cn } from '@/lib/utils' +import { Badge } from '@/components/ui/badge' + +interface ExerciseResultSummaryProps { + score: number | null + passed: boolean + /** Absent for engines that record a completion but no AI feedback (code). */ + feedback?: string | null + strengths?: string[] + improvements?: string[] + attemptNumber?: number | null + completedAt?: string | null + passingScore?: number + className?: string +} + +/** Prose measure. The design law caps body text at 65-75ch; the card sits in a + * `lg:col-span-8` column that is otherwise wide enough to run past 85ch. */ +const PROSE = 'max-w-[68ch] break-words' + +/** + * The student's last graded attempt on a standalone exercise, shown when they + * come back to it. Previously every one of these engines held its result in + * component state only, so returning to a finished exercise showed a bare + * "Completed" badge and the feedback they had already earned was gone. + */ +export default function ExerciseResultSummary({ + score, + passed, + feedback, + strengths = [], + improvements = [], + attemptNumber, + completedAt, + passingScore, + className, +}: ExerciseResultSummaryProps) { + const t = useTranslations('exercises.result') + const tAudio = useTranslations('exercises.audio') + const tArtifact = useTranslations('exercises.artifact') + const locale = useLocale() + + const meta = [ + typeof attemptNumber === 'number' ? tAudio('attempt', { number: attemptNumber }) : null, + typeof passingScore === 'number' ? t('passMark', { score: passingScore }) : null, + completedAt + ? tAudio('completedOn', { + date: new Date(completedAt).toLocaleDateString(locale, { + month: 'long', + day: 'numeric', + year: 'numeric', + }), + }) + : null, + ].filter(Boolean) + + return ( +
+ {/* Stacks below sm: at 390px the metadata line is squeezed to ~160px by + the score block, which cannot shrink (the badge is whitespace-nowrap). */} +
+
+

+ {passed ? ( + + ) : ( + + )} + {t('title')} +

+ {meta.length > 0 && ( +

{meta.join(' · ')}

+ )} +
+ +
+ {score !== null && ( +
+ + {Math.round(score)} + + {/* Denominator is the scale, not the pass mark — every engine + clamps to 0-100, so "88 / 70" would read as 88 out of 70. */} + / 100 +
+ )} + + {passed ? tArtifact('passed') : tArtifact('failed')} + +
+
+ + {feedback && ( +
+ {/* The label is foreground, not `text-primary`: primary is overridden + per tenant, so small text in it cannot be guaranteed to clear AA + (the default hue measured 3.35:1 in dark). The icon keeps the + accent — non-text UI only needs 3:1. */} +

+

+

{feedback}

+
+ )} + + {strengths.length > 0 && ( +
+

+ {tAudio('strengths')} +

+
    + {strengths.map((item, i) => ( +
  • +
  • + ))} +
+
+ )} + + {improvements.length > 0 && ( +
+

+ {tAudio('improvements')} +

+
    + {improvements.map((item, i) => ( + // An arrow, not a minus: these are next steps, not deductions. +
  • +
  • + ))} +
+
+ )} + + {!feedback && strengths.length === 0 && improvements.length === 0 && ( + // Code challenges are graded by their test runner and store no feedback. + // Say so, rather than leaving an unexplained empty card. +

{t('noFeedbackRecorded')}

+ )} +
+ ) +} diff --git a/components/lesson/checkpoints/checkpoint-exercise-renderer.tsx b/components/lesson/checkpoints/checkpoint-exercise-renderer.tsx index 891ee95c5..38151da4a 100644 --- a/components/lesson/checkpoints/checkpoint-exercise-renderer.tsx +++ b/components/lesson/checkpoints/checkpoint-exercise-renderer.tsx @@ -3,7 +3,14 @@ import { useState } from 'react' import Link from 'next/link' import { useTranslations } from 'next-intl' -import { IconCheck, IconX, IconLoader2, IconExternalLink, IconRefresh } from '@tabler/icons-react' +import { + IconCheck, + IconX, + IconLoader2, + IconExternalLink, + IconRefresh, + IconTarget, +} from '@tabler/icons-react' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' @@ -14,6 +21,7 @@ import { EXTERNAL_EXERCISE_TYPES, type CheckpointAnswer, type CheckpointAttemptResult, + type ClientCheckpointQuestion, } from '@/lib/checkpoints/types' import type { LessonCheckpointClientData } from '@/lib/checkpoints/load' @@ -69,6 +77,73 @@ async function postAttempt(checkpointId: number, body: unknown): Promise> +): string { + if (question.type === 'multiple_choice' && typeof value === 'number') { + return question.options?.[value] ?? String(value) + } + if (typeof value === 'boolean') return value ? t('trueLabel') : t('falseLabel') + return String(value) +} + +/** + * The student's own submitted text, shown alongside the feedback it earned. + * + * Deliberately borderless: with a border and a pale fill it was visually the + * same object as the retry Textarea directly below it, so the read-only record + * of a past answer read as an editable field. + */ +function SubmittedAnswer({ text }: { text: string }) { + const t = useTranslations('components.checkpoints') + return ( +
+

{t('yourAnswer')}

+

+ {text} +

+
+ ) +} + export function CheckpointExerciseRenderer({ checkpoint }: CheckpointExerciseRendererProps) { const { exercise } = checkpoint const isClosed = @@ -81,9 +156,18 @@ export function CheckpointExerciseRenderer({ checkpoint }: CheckpointExerciseRen return } -function ResultAlert({ result }: { result: CheckpointAttemptResult }) { +function ResultAlert({ + result, + maxAttempts, +}: { + result: CheckpointAttemptResult + /** Only meaningful for AI-graded checkpoints that allow more than one try. */ + maxAttempts?: number +}) { const t = useTranslations('components.checkpoints') const positive = result.passed !== false + const showAttempts = + result.evaluatorType === 'ai' && maxAttempts !== undefined && maxAttempts > 1 return (
-
- {positive ? : } + {/* Wraps: "Puntuación: 64 / 100" plus "Intento 1 de 3" runs past 320px. */} +
+ {positive ? ( + + ) : ( + + )} + {/* Same grammar as the standalone exercise card: a score out of 100, + never over the pass mark. */} {result.score !== null - ? t('scoreLabel', { score: Math.round(result.score) }) + ? t('scoreOutOf', { score: Math.round(result.score) }) : result.passed ? t('passed') : t('notPassed')} + {showAttempts && ( + + {t('attemptCounter', { attempt: result.attemptNumber, total: maxAttempts })} + + )}
- {result.feedback &&

{result.feedback}

} + {result.feedback && ( +

{result.feedback}

+ )} {result.nextStepHint && ( -

+

{t('nextStep')}: {result.nextStepHint}

)} @@ -120,13 +218,17 @@ function ClosedCheckpointForm({ checkpoint }: CheckpointExerciseRendererProps) { const ctx = useCheckpoints() const recordResult = ctx?.recordResult const questions = checkpoint.exercise.questions ?? [] - const [answers, setAnswers] = useState>({}) + const [answers, setAnswers] = useState>( + () => checkpoint.latestAttempt?.submittedAnswers ?? {} + ) const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) // Seed from the provider: this form remounts whenever the provider updates // (the MDX tree is recreated), so local-only state would lose the feedback. + // Falling back to the persisted attempt is what makes the graded answers and + // per-question explanations survive a page reload, not just a remount. const [result, setResult] = useState( - () => ctx?.getResult(checkpoint.id) ?? null + () => ctx?.getResult(checkpoint.id) ?? restoredResult(checkpoint) ) const allAnswered = questions.every((q) => answers[q.id] !== undefined) @@ -146,7 +248,7 @@ function ClosedCheckpointForm({ checkpoint }: CheckpointExerciseRendererProps) { return } setResult(res.data) - recordResult?.(checkpoint.id, res.data) + recordResult?.(checkpoint.id, res.data, { answers }) } function handleRetry() { @@ -174,9 +276,12 @@ function ClosedCheckpointForm({ checkpoint }: CheckpointExerciseRendererProps) { disabled={result !== null} > {(q.options ?? []).map((opt, optIdx) => ( + // items-start, not items-center: a two-line option pushed the + // radio to the vertical middle of the wrapped text. py-1 also + // lifts the tap target off the 20px text line height.