- )}
+ {/* 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' && (
+
)}
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
+
+ {/* 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. */}
+
+
+ {tAudio('aiFeedback')}
+
+
{feedback}
+
+ )}
+
+ {strengths.length > 0 && (
+
+
+ {tAudio('strengths')}
+
+
+ {strengths.map((item, i) => (
+
+
+ {item}
+
+ ))}
+
+
+ )}
+
+ {improvements.length > 0 && (
+
+
+ {tAudio('improvements')}
+
+
+ {improvements.map((item, i) => (
+ // An arrow, not a minus: these are next steps, not deductions.
+
+
+ {item}
+
+ ))}
+
+
+ )}
+
+ {!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.