From 7a847d9d8301b7b7bd657cb7fca77ebc8a6cce18 Mon Sep 17 00:00:00 2001 From: Guillermo Marin <52298929+guillermoscript@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:26:54 +0200 Subject: [PATCH] feat(exercises): show students their past AI task results, legibly (#581) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A student returning to a finished lesson checkpoint or standalone exercise saw a bare score badge. The AI's feedback was never missing — it was always written to `lesson_checkpoint_attempts.evaluation` and `exercise_evaluations. ai_result`; the loaders simply never read those columns back, and every engine held its result in component state that a reload discarded. Restore path - `loadLessonCheckpoints` now selects `response` and `evaluation`, and defensive parsers turn those two free-form jsonb columns back into the student's own answer plus the feedback, next-step hint and per-question explanations. Four writer shapes exist (ai / deterministic / fallback / external sync); anything unrecognised yields "nothing to show" rather than being trusted into the UI. - The standalone exercise page needed no new query: artifact and essay rows were already in the `exercise_evaluations` result set fetched for the audio/video history and were simply never passed to their components. - Per-question feedback rendered `correctValue` raw, so a multiple-choice miss read "Incorrect — 1" (the option *index*). It now maps to option text and localized true/false. - Coding challenges write no evaluation row at all — they are graded by their test runner and store a hardcoded completion. The card says so instead of rendering an empty result. Legibility (issue #581, found by an /impeccable critique verified live at 390x844 and 1440x900, light and dark, en and es) - Six WCAG AA contrast failures in light and four in dark are fixed; the card now measures clean in both themes. The worst was the PASSED badge at 2.47:1 — white on emerald-500, the single most important word on the card. The "AI Feedback" label no longer uses `text-primary`: tenants override that token, so small text in it cannot be guaranteed to clear AA. - The artifact panel printed the score over the PASS MARK, twice: "62 / 70" reads as 89% when 62 is a failure. That whole block was a near-copy of ExerciseResultSummary, so it now renders the shared card instead. - A failed checkpoint showed a green tick, because its tone came from `completed` and a failed attempt is still completed. - The checkpoint restored behind a closed disclosure. It now opens itself for the one state that asks something of the student — a graded attempt they did not pass — and an explicit toggle still wins. - The retry field sat between the answer and the grade for that answer, and looked identical to the read-only answer above it. Order is now answer → result → retry, and the restored answer is borderless so it does not read as an editable field. - Adding the result card inside `lg:sticky lg:top-6` took that container to 1078px against a 900px viewport; `sticky` does not pin an element taller than its scrollport, so the chat input scrolled away unreachable. The card is now a sibling above it, which also puts the result first on mobile instead of 1157px down the page behind instructions already followed. - Prose is capped at 68ch (was 85-90), the header stacks below `sm:`, long unbroken input wraps, and on phones the retry button is 36px tall and inputs are 16px so iOS Safari stops zooming the page on focus. 23 unit tests pin the parsers and the auto-expand rule. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JUBJNELGqnwbQbQNp5Qd9h --- .../exercises/[exerciseId]/page.tsx | 58 +++++- components/exercises/artifact-exercise.tsx | 122 +++--------- components/exercises/code-exercise.tsx | 5 + components/exercises/essay-exercise.tsx | 11 ++ .../exercises/exercise-result-summary.tsx | 180 +++++++++++++++++ .../checkpoint-exercise-renderer.tsx | 187 ++++++++++++++---- .../checkpoints/checkpoints-provider.tsx | 41 +++- .../lesson/checkpoints/lesson-checkpoint.tsx | 28 ++- lib/checkpoints/load.ts | 23 ++- lib/checkpoints/types.ts | 92 +++++++++ lib/exercises/latest-evaluation.ts | 72 +++++++ messages/en.json | 8 + messages/es.json | 8 + tests/unit/checkpoint-attempt-restore.test.ts | 180 +++++++++++++++++ tests/unit/exercise-latest-evaluation.test.ts | 112 +++++++++++ 15 files changed, 990 insertions(+), 137 deletions(-) create mode 100644 components/exercises/exercise-result-summary.tsx create mode 100644 lib/exercises/latest-evaluation.ts create mode 100644 tests/unit/checkpoint-attempt-restore.test.ts create mode 100644 tests/unit/exercise-latest-evaluation.test.ts 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.