Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -171,15 +173,23 @@ 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))
.eq('user_id', userId)
.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),
Expand All @@ -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 {
Expand Down Expand Up @@ -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 ? (
<ExerciseResultSummary
score={latestEvaluation.score}
passed={latestEvaluation.passed}
feedback={latestEvaluation.feedback}
strengths={latestEvaluation.strengths}
improvements={latestEvaluation.improvements}
attemptNumber={latestEvaluation.attemptNumber}
completedAt={latestEvaluation.createdAt}
passingScore={passingScore}
/>
) : null

const codeResultSummary = codeCompletion ? (
<ExerciseResultSummary
score={codeCompletion.score ?? null}
passed
completedAt={codeCompletion.completed_at ?? null}
/>
) : null

const chatComponent = (
<ExerciseChat
apiEndpoint="/api/chat/exercises/student"
Expand All @@ -287,6 +327,7 @@ export default async function ExercisePage({ params }: PageProps) {
isExerciseCompleted={isExerciseCompleted}
studentId={userId}
courseId={courseId}
resultSummary={codeResultSummary}
>
<CodeChallengeWrapper
exercise={exercise}
Expand Down Expand Up @@ -325,6 +366,18 @@ export default async function ExercisePage({ params }: PageProps) {
isExerciseCompleted={isExerciseCompleted}
passingScore={passingScore}
isExerciseCompletedSection={otherExercisesSection}
initialEvaluation={
latestEvaluation
? {
score: latestEvaluation.score ?? 0,
feedback: latestEvaluation.feedback ?? '',
passed: latestEvaluation.passed,
strengths: latestEvaluation.strengths,
improvements: latestEvaluation.improvements,
passingScore,
}
: null
}
/>
) : exercise.exercise_type === 'audio_evaluation' ? (
<AudioExercise
Expand Down Expand Up @@ -355,6 +408,7 @@ export default async function ExercisePage({ params }: PageProps) {
profile={profile}
studentId={userId}
isExerciseCompletedSection={otherExercisesSection}
resultSummary={resultSummary}
>
{chatComponent}
</EssayExercise>
Expand Down
122 changes: 30 additions & 92 deletions components/exercises/artifact-exercise.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -16,7 +17,6 @@ import {
IconSparkles,
IconLoader2,
IconAlertTriangle,
IconTarget,
IconArrowRight,
IconTrophy,
IconCode,
Expand All @@ -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 {
Expand All @@ -58,6 +61,7 @@ export default function ArtifactExercise({
isExerciseCompleted,
passingScore,
isExerciseCompletedSection,
initialEvaluation = null,
}: ArtifactExerciseProps) {
const t = useTranslations('exercises.artifact')
const tAudio = useTranslations('exercises.audio')
Expand All @@ -68,7 +72,7 @@ export default function ArtifactExercise({

const iframeRef = useRef<HTMLIFrameElement>(null)
const [submitState, setSubmitState] = useState<SubmitState>('idle')
const [evaluation, setEvaluation] = useState<EvaluationResult | null>(null)
const [evaluation, setEvaluation] = useState<EvaluationResult | null>(initialEvaluation)
const [passed, setPassed] = useState<boolean>(isExerciseCompleted)
const [errorMsg, setErrorMsg] = useState<string | null>(null)
const [rateLimited, setRateLimited] = useState(false)
Expand Down Expand Up @@ -303,98 +307,32 @@ export default function ArtifactExercise({
</div>
)}

{/* Evaluation result */}
{evaluation && submitState === 'done' && (
<div className="rounded-xl border bg-card p-5 space-y-5">
<h3 className="text-sm font-semibold flex items-center gap-2">
<IconSparkles size={16} className="text-primary" />
{tAudio('aiFeedback')}
</h3>

{/* Score */}
<div className="flex items-center justify-between">
<div className="flex items-baseline gap-3">
<div className="flex items-baseline gap-1">
<span className={cn(
'text-4xl font-black tabular-nums tracking-tight',
evaluation.passed ? 'text-emerald-600 dark:text-emerald-400' : 'text-foreground'
)}>
{evaluation.score}
</span>
<span className="text-lg font-bold text-muted-foreground/60">/</span>
<span className="text-lg font-bold text-primary tabular-nums">{evaluation.passingScore}</span>
</div>
</div>
<Badge className={cn(
'font-bold text-xs px-3 py-1',
evaluation.passed
? 'bg-emerald-500 text-white'
: 'bg-amber-500/10 text-amber-700 border-amber-500/30'
)}>
{evaluation.passed ? t('passed') : t('failed')}
</Badge>
</div>

{/* Feedback */}
<p className="text-sm text-foreground/80 leading-relaxed">{evaluation.feedback}</p>

{/* Strengths */}
{evaluation.strengths.length > 0 && (
<div>
<p className="text-xs font-bold uppercase tracking-widest text-emerald-600 dark:text-emerald-400 mb-2">
{t('strengths')}
</p>
<ul className="space-y-1.5">
{evaluation.strengths.map((s, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-foreground/80">
<span className="mt-0.5 shrink-0 text-emerald-500">+</span>
{s}
</li>
))}
</ul>
</div>
)}

{/* Improvements */}
{evaluation.improvements.length > 0 && (
<div>
<p className="text-xs font-bold uppercase tracking-widest text-amber-600 dark:text-amber-400 mb-2">
{t('improvements')}
</p>
<ul className="space-y-1.5">
{evaluation.improvements.map((imp, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-foreground/80">
<span className="mt-0.5 shrink-0 text-amber-500">-</span>
{imp}
</li>
))}
</ul>
</div>
)}
{/* 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' && (
<div className="space-y-4">
<ExerciseResultSummary
score={evaluation.score}
passed={evaluation.passed}
feedback={evaluation.feedback}
strengths={evaluation.strengths}
improvements={evaluation.improvements}
// The API response carries its own threshold; the prop is the
// fallback for a restored attempt that predates that field.
passingScore={evaluation.passingScore ?? passingScore}
/>

{/* Try again */}
{!evaluation.passed && !rateLimited && (
<div className="rounded-2xl border-2 border-primary/15 bg-gradient-to-br from-primary/[0.04] to-primary/[0.01] p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-baseline gap-1">
<span className="text-2xl font-black tabular-nums tracking-tight text-foreground">
{evaluation.score}
</span>
<span className="text-sm font-bold text-muted-foreground/60">/</span>
<span className="text-sm font-bold text-primary tabular-nums">{evaluation.passingScore}</span>
</div>
<div className="rounded-full border-2 border-primary/20 bg-primary/5 p-2">
<IconTarget size={18} className="text-primary" />
</div>
</div>
<Button
onClick={handleTryAgain}
className="w-full gap-2.5 h-11 text-sm font-bold tracking-wide"
>
{t('tryAgain')}
<IconArrowRight size={16} className="ml-auto opacity-60" />
</Button>
</div>
<Button
onClick={handleTryAgain}
className="w-full gap-2.5 h-11 text-sm font-semibold tracking-wide"
>
{t('tryAgain')}
<IconArrowRight size={16} className="ml-auto opacity-60" />
</Button>
)}
</div>
)}
Expand Down
5 changes: 5 additions & 0 deletions components/exercises/code-exercise.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="space-y-6 pb-20">
Expand All @@ -42,6 +45,8 @@ export default function CodeExercise({
</div>
</motion.div>

{resultSummary}

<Tabs defaultValue="environment" className="space-y-4">
<TabsList className="bg-muted/50 p-1">
<TabsTrigger value="environment" className="gap-2">
Expand Down
11 changes: 11 additions & 0 deletions components/exercises/essay-exercise.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { label: string; color: string; icon: typeof IconFlame }> = {
Expand All @@ -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;
Expand Down Expand Up @@ -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 && (
<div className="lg:col-span-12 order-first">{resultSummary}</div>
)}

{/* Instructions */}
<div className="lg:col-span-4 order-1">
<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">
Expand Down
Loading
Loading