Skip to content

Commit 84b22dc

Browse files
feat(exercises): show students their past AI task results, legibly (#581) (#582)
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. Claude-Session: https://claude.ai/code/session_01JUBJNELGqnwbQbQNp5Qd9h Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d110d11 commit 84b22dc

15 files changed

Lines changed: 990 additions & 137 deletions

File tree

app/[locale]/dashboard/student/courses/[courseId]/exercises/[exerciseId]/page.tsx

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { requireCourseAccess, requireRowInCourse } from '@/lib/services/course-a
55
import { getTranslations } from 'next-intl/server'
66
import { EXTERNAL_EXERCISE_TYPES } from '@/lib/checkpoints/types'
77
import { getCheckpointLinkedExerciseIds } from '@/lib/checkpoints/load'
8+
import { toLatestEvaluation, type LatestExerciseEvaluation } from '@/lib/exercises/latest-evaluation'
9+
import ExerciseResultSummary from '@/components/exercises/exercise-result-summary'
810
import type { SpeechEvaluation } from '@/lib/speech/types'
911

1012
import dynamic from 'next/dynamic'
@@ -171,15 +173,23 @@ export default async function ExercisePage({ params }: PageProps) {
171173

172174
// Fetch evaluation history from unified exercise_evaluations table
173175
let submissionHistory: { id: number; ai_evaluation: SpeechEvaluation | null; score: number | null; status: string; media_url: string; created_at: string; duration_seconds: number | null }[] = []
176+
// Newest graded attempt, replayed to the student when they come back to a
177+
// finished exercise. Derived from the same rows as submissionHistory — the
178+
// artifact/essay engines write here too, their results were just never read.
179+
let latestEvaluation: LatestExerciseEvaluation | null = null
174180
try {
175-
const { data: evaluations } = await supabase
181+
const { data: evaluations, error: evaluationsError } = await supabase
176182
.from('exercise_evaluations')
177183
.select('id, score, passed, ai_result, ai_metrics, engine_type, attempt_number, created_at')
178184
.eq('exercise_id', parseInt(exerciseId))
179185
.eq('user_id', userId)
180186
.eq('tenant_id', tenantId)
181187
.order('created_at', { ascending: false })
182188

189+
// PostgREST reports failures in `error`, not by throwing, so without this
190+
// the catch below never fires and a broken query looks like "no attempts".
191+
if (evaluationsError) console.error('Error fetching exercise evaluations:', evaluationsError)
192+
183193
// Map to legacy format for AudioExercise component compatibility
184194
submissionHistory = (evaluations ?? []).map(ev => ({
185195
id: Number(ev.id),
@@ -192,8 +202,11 @@ export default async function ExercisePage({ params }: PageProps) {
192202
(ev.ai_metrics as unknown as { duration_seconds?: number | null } | null)
193203
?.duration_seconds ?? null,
194204
}))
195-
} catch {
205+
206+
latestEvaluation = toLatestEvaluation(evaluations?.[0] ?? null)
207+
} catch (err) {
196208
// RLS or table access may fail — gracefully degrade
209+
console.error('Error fetching exercise evaluations:', err)
197210
}
198211

199212
const exerciseConfig = exercise.exercise_config as unknown as {
@@ -267,6 +280,33 @@ export default async function ExercisePage({ params }: PageProps) {
267280
</>
268281
) : null
269282

283+
// Code challenges are graded by their own test runner and never write an
284+
// exercise_evaluations row, so their reviewable record is the completion.
285+
const codeCompletion = exercise.exercise_completions?.[0] as
286+
| { score?: number | null; completed_at?: string | null }
287+
| undefined
288+
289+
const resultSummary = latestEvaluation ? (
290+
<ExerciseResultSummary
291+
score={latestEvaluation.score}
292+
passed={latestEvaluation.passed}
293+
feedback={latestEvaluation.feedback}
294+
strengths={latestEvaluation.strengths}
295+
improvements={latestEvaluation.improvements}
296+
attemptNumber={latestEvaluation.attemptNumber}
297+
completedAt={latestEvaluation.createdAt}
298+
passingScore={passingScore}
299+
/>
300+
) : null
301+
302+
const codeResultSummary = codeCompletion ? (
303+
<ExerciseResultSummary
304+
score={codeCompletion.score ?? null}
305+
passed
306+
completedAt={codeCompletion.completed_at ?? null}
307+
/>
308+
) : null
309+
270310
const chatComponent = (
271311
<ExerciseChat
272312
apiEndpoint="/api/chat/exercises/student"
@@ -287,6 +327,7 @@ export default async function ExercisePage({ params }: PageProps) {
287327
isExerciseCompleted={isExerciseCompleted}
288328
studentId={userId}
289329
courseId={courseId}
330+
resultSummary={codeResultSummary}
290331
>
291332
<CodeChallengeWrapper
292333
exercise={exercise}
@@ -325,6 +366,18 @@ export default async function ExercisePage({ params }: PageProps) {
325366
isExerciseCompleted={isExerciseCompleted}
326367
passingScore={passingScore}
327368
isExerciseCompletedSection={otherExercisesSection}
369+
initialEvaluation={
370+
latestEvaluation
371+
? {
372+
score: latestEvaluation.score ?? 0,
373+
feedback: latestEvaluation.feedback ?? '',
374+
passed: latestEvaluation.passed,
375+
strengths: latestEvaluation.strengths,
376+
improvements: latestEvaluation.improvements,
377+
passingScore,
378+
}
379+
: null
380+
}
328381
/>
329382
) : exercise.exercise_type === 'audio_evaluation' ? (
330383
<AudioExercise
@@ -355,6 +408,7 @@ export default async function ExercisePage({ params }: PageProps) {
355408
profile={profile}
356409
studentId={userId}
357410
isExerciseCompletedSection={otherExercisesSection}
411+
resultSummary={resultSummary}
358412
>
359413
{chatComponent}
360414
</EssayExercise>

components/exercises/artifact-exercise.tsx

Lines changed: 30 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useState, useRef, useCallback, useEffect } from 'react'
44
import { Badge } from '@/components/ui/badge'
55
import { Button } from '@/components/ui/button'
6+
import ExerciseResultSummary from '@/components/exercises/exercise-result-summary'
67
import { cn } from '@/lib/utils'
78
import Markdown from 'react-markdown'
89
import { useTranslations } from 'next-intl'
@@ -16,7 +17,6 @@ import {
1617
IconSparkles,
1718
IconLoader2,
1819
IconAlertTriangle,
19-
IconTarget,
2020
IconArrowRight,
2121
IconTrophy,
2222
IconCode,
@@ -40,6 +40,9 @@ interface ArtifactExerciseProps {
4040
isExerciseCompleted: boolean
4141
passingScore: number
4242
isExerciseCompletedSection?: React.ReactNode
43+
/** Last graded attempt from exercise_evaluations. The route has always written
44+
* one; it was simply never read back, so reloading the page lost the feedback. */
45+
initialEvaluation?: EvaluationResult | null
4346
}
4447

4548
interface EvaluationResult {
@@ -58,6 +61,7 @@ export default function ArtifactExercise({
5861
isExerciseCompleted,
5962
passingScore,
6063
isExerciseCompletedSection,
64+
initialEvaluation = null,
6165
}: ArtifactExerciseProps) {
6266
const t = useTranslations('exercises.artifact')
6367
const tAudio = useTranslations('exercises.audio')
@@ -68,7 +72,7 @@ export default function ArtifactExercise({
6872

6973
const iframeRef = useRef<HTMLIFrameElement>(null)
7074
const [submitState, setSubmitState] = useState<SubmitState>('idle')
71-
const [evaluation, setEvaluation] = useState<EvaluationResult | null>(null)
75+
const [evaluation, setEvaluation] = useState<EvaluationResult | null>(initialEvaluation)
7276
const [passed, setPassed] = useState<boolean>(isExerciseCompleted)
7377
const [errorMsg, setErrorMsg] = useState<string | null>(null)
7478
const [rateLimited, setRateLimited] = useState(false)
@@ -303,98 +307,32 @@ export default function ArtifactExercise({
303307
</div>
304308
)}
305309

306-
{/* Evaluation result */}
307-
{evaluation && submitState === 'done' && (
308-
<div className="rounded-xl border bg-card p-5 space-y-5">
309-
<h3 className="text-sm font-semibold flex items-center gap-2">
310-
<IconSparkles size={16} className="text-primary" />
311-
{tAudio('aiFeedback')}
312-
</h3>
313-
314-
{/* Score */}
315-
<div className="flex items-center justify-between">
316-
<div className="flex items-baseline gap-3">
317-
<div className="flex items-baseline gap-1">
318-
<span className={cn(
319-
'text-4xl font-black tabular-nums tracking-tight',
320-
evaluation.passed ? 'text-emerald-600 dark:text-emerald-400' : 'text-foreground'
321-
)}>
322-
{evaluation.score}
323-
</span>
324-
<span className="text-lg font-bold text-muted-foreground/60">/</span>
325-
<span className="text-lg font-bold text-primary tabular-nums">{evaluation.passingScore}</span>
326-
</div>
327-
</div>
328-
<Badge className={cn(
329-
'font-bold text-xs px-3 py-1',
330-
evaluation.passed
331-
? 'bg-emerald-500 text-white'
332-
: 'bg-amber-500/10 text-amber-700 border-amber-500/30'
333-
)}>
334-
{evaluation.passed ? t('passed') : t('failed')}
335-
</Badge>
336-
</div>
337-
338-
{/* Feedback */}
339-
<p className="text-sm text-foreground/80 leading-relaxed">{evaluation.feedback}</p>
340-
341-
{/* Strengths */}
342-
{evaluation.strengths.length > 0 && (
343-
<div>
344-
<p className="text-xs font-bold uppercase tracking-widest text-emerald-600 dark:text-emerald-400 mb-2">
345-
{t('strengths')}
346-
</p>
347-
<ul className="space-y-1.5">
348-
{evaluation.strengths.map((s, i) => (
349-
<li key={i} className="flex items-start gap-2 text-sm text-foreground/80">
350-
<span className="mt-0.5 shrink-0 text-emerald-500">+</span>
351-
{s}
352-
</li>
353-
))}
354-
</ul>
355-
</div>
356-
)}
357-
358-
{/* Improvements */}
359-
{evaluation.improvements.length > 0 && (
360-
<div>
361-
<p className="text-xs font-bold uppercase tracking-widest text-amber-600 dark:text-amber-400 mb-2">
362-
{t('improvements')}
363-
</p>
364-
<ul className="space-y-1.5">
365-
{evaluation.improvements.map((imp, i) => (
366-
<li key={i} className="flex items-start gap-2 text-sm text-foreground/80">
367-
<span className="mt-0.5 shrink-0 text-amber-500">-</span>
368-
{imp}
369-
</li>
370-
))}
371-
</ul>
372-
</div>
373-
)}
310+
{/* Evaluation result — also shown for a restored prior attempt, whose
311+
submitState is still 'idle' because nothing was submitted this visit. */}
312+
{/* One result card across every engine. This block used to be a
313+
near-copy of ExerciseResultSummary that printed the score over
314+
the PASS MARK, twice: "62 / 70" reads as 89% when 62 is a fail. */}
315+
{evaluation && submitState !== 'evaluating' && (
316+
<div className="space-y-4">
317+
<ExerciseResultSummary
318+
score={evaluation.score}
319+
passed={evaluation.passed}
320+
feedback={evaluation.feedback}
321+
strengths={evaluation.strengths}
322+
improvements={evaluation.improvements}
323+
// The API response carries its own threshold; the prop is the
324+
// fallback for a restored attempt that predates that field.
325+
passingScore={evaluation.passingScore ?? passingScore}
326+
/>
374327

375-
{/* Try again */}
376328
{!evaluation.passed && !rateLimited && (
377-
<div className="rounded-2xl border-2 border-primary/15 bg-gradient-to-br from-primary/[0.04] to-primary/[0.01] p-5">
378-
<div className="flex items-center justify-between mb-4">
379-
<div className="flex items-baseline gap-1">
380-
<span className="text-2xl font-black tabular-nums tracking-tight text-foreground">
381-
{evaluation.score}
382-
</span>
383-
<span className="text-sm font-bold text-muted-foreground/60">/</span>
384-
<span className="text-sm font-bold text-primary tabular-nums">{evaluation.passingScore}</span>
385-
</div>
386-
<div className="rounded-full border-2 border-primary/20 bg-primary/5 p-2">
387-
<IconTarget size={18} className="text-primary" />
388-
</div>
389-
</div>
390-
<Button
391-
onClick={handleTryAgain}
392-
className="w-full gap-2.5 h-11 text-sm font-bold tracking-wide"
393-
>
394-
{t('tryAgain')}
395-
<IconArrowRight size={16} className="ml-auto opacity-60" />
396-
</Button>
397-
</div>
329+
<Button
330+
onClick={handleTryAgain}
331+
className="w-full gap-2.5 h-11 text-sm font-semibold tracking-wide"
332+
>
333+
{t('tryAgain')}
334+
<IconArrowRight size={16} className="ml-auto opacity-60" />
335+
</Button>
398336
)}
399337
</div>
400338
)}

components/exercises/code-exercise.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,15 @@ interface CodeExerciseProps {
1212
studentId: string;
1313
courseId: string;
1414
children: ReactNode;
15+
/** Prior completion record, shown when the student returns to a solved challenge. */
16+
resultSummary?: ReactNode;
1517
}
1618

1719
export default function CodeExercise({
1820
exercise,
1921
isExerciseCompleted,
2022
children,
23+
resultSummary,
2124
}: CodeExerciseProps) {
2225
return (
2326
<div className="space-y-6 pb-20">
@@ -42,6 +45,8 @@ export default function CodeExercise({
4245
</div>
4346
</motion.div>
4447

48+
{resultSummary}
49+
4550
<Tabs defaultValue="environment" className="space-y-4">
4651
<TabsList className="bg-muted/50 p-1">
4752
<TabsTrigger value="environment" className="gap-2">

components/exercises/essay-exercise.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ interface EssayExerciseProps {
1616
studentId: string;
1717
children: ReactNode;
1818
isExerciseCompletedSection?: ReactNode;
19+
/** Last graded attempt, shown above the chat when the student returns. */
20+
resultSummary?: ReactNode;
1921
}
2022

2123
const difficultyConfig: Record<string, { label: string; color: string; icon: typeof IconFlame }> = {
@@ -40,6 +42,7 @@ export default function EssayExercise({
4042
isExerciseCompleted,
4143
children,
4244
isExerciseCompletedSection,
45+
resultSummary,
4346
}: EssayExerciseProps) {
4447
const difficulty = difficultyConfig[exercise.difficulty_level] || difficultyConfig.easy;
4548
const DifficultyIcon = difficulty.icon;
@@ -94,6 +97,14 @@ export default function EssayExercise({
9497
transition={{ duration: 0.35, delay: 0.1 }}
9598
className="grid grid-cols-1 lg:grid-cols-12 gap-4 sm:gap-6"
9699
>
100+
{/* Last graded attempt — first for a returning student, and
101+
deliberately OUTSIDE the sticky chat column: adding it there
102+
pushed that column past the viewport height, and `sticky`
103+
stops pinning once the element is taller than its scrollport. */}
104+
{resultSummary && (
105+
<div className="lg:col-span-12 order-first">{resultSummary}</div>
106+
)}
107+
97108
{/* Instructions */}
98109
<div className="lg:col-span-4 order-1">
99110
<div className="rounded-xl sm:rounded-2xl border sm:border-2 border-primary/10 bg-gradient-to-b from-primary/[0.03] to-transparent overflow-hidden">

0 commit comments

Comments
 (0)