From c8328070a4ab37274cec725abde61e0ccb21bd76 Mon Sep 17 00:00:00 2001 From: jvcByte Date: Fri, 12 Jun 2026 13:24:37 +0100 Subject: [PATCH 01/22] Add comprehensive performance analysis report for June 2026 recoding - Overview metrics: pass/fail rates, total participants - Question-level breakdown showing why students passed/failed - Student trajectories: high performers, moderate, struggling, at-risk - Flagged submissions analysis - Individual student records with CSV export - Accessible from instructor dashboard --- .../instructor/reports/[exerciseId]/route.ts | 147 ++++++++ app/instructor/page.tsx | 9 +- app/instructor/reports/ReportView.tsx | 325 ++++++++++++++++++ app/instructor/reports/page.tsx | 40 +++ 4 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 app/api/instructor/reports/[exerciseId]/route.ts create mode 100644 app/instructor/reports/ReportView.tsx create mode 100644 app/instructor/reports/page.tsx diff --git a/app/api/instructor/reports/[exerciseId]/route.ts b/app/api/instructor/reports/[exerciseId]/route.ts new file mode 100644 index 0000000..7a84f73 --- /dev/null +++ b/app/api/instructor/reports/[exerciseId]/route.ts @@ -0,0 +1,147 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { sql } from '@/lib/db'; + +export async function GET( + req: NextRequest, + { params }: { params: { exerciseId: string } } +) { + const session = await getServerSession(authOptions); + if (session?.user?.role !== 'instructor') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { exerciseId } = params; + + try { + // Overview statistics + const overviewResult = await sql` + SELECT + COUNT(DISTINCT s.user_id) as total_participants, + COUNT(DISTINCT CASE WHEN s.passed = true THEN s.user_id END) as passed_count, + COUNT(DISTINCT CASE WHEN s.passed = false THEN s.user_id END) as failed_count + FROM sessions s + WHERE s.exercise_id = ${exerciseId} + `; + + const overview = overviewResult[0]; + const totalParticipants = Number(overview.total_participants); + const passedCount = Number(overview.passed_count); + const failedCount = Number(overview.failed_count); + const passRate = totalParticipants > 0 ? (passedCount / totalParticipants) * 100 : 0; + + // Question-level performance + const questionPerformance = await sql` + SELECT + q.question_index, + SUBSTRING(q.text FROM 1 FOR 100) as title, + COUNT(sub.id) as total_submissions, + COUNT(CASE WHEN sub.tests_passed = true THEN 1 END) as passed_submissions, + ROUND(100.0 * COUNT(CASE WHEN sub.tests_passed = true THEN 1 END) / NULLIF(COUNT(sub.id), 0), 1) as pass_rate + FROM questions q + LEFT JOIN submissions sub ON + sub.session_id IN (SELECT id FROM sessions WHERE exercise_id = ${exerciseId}) + AND sub.question_index = q.question_index + WHERE q.exercise_id = ${exerciseId} + GROUP BY q.question_index, q.text + ORDER BY q.question_index + `; + + // Flagged submissions analysis + const flaggedAnalysis = await sql` + SELECT + COUNT(DISTINCT sub.id) as total_flagged, + COUNT(DISTINCT CASE WHEN 'focus_loss' = ANY(sub.flag_reasons) THEN sub.id END) as focus_loss, + COUNT(DISTINCT CASE WHEN 'paste' = ANY(sub.flag_reasons) THEN sub.id END) as paste, + COUNT(DISTINCT CASE WHEN 'low_edits' = ANY(sub.flag_reasons) THEN sub.id END) as low_edits + FROM submissions sub + WHERE sub.session_id IN (SELECT id FROM sessions WHERE exercise_id = ${exerciseId}) + AND sub.is_flagged = true + `; + + const flagged = flaggedAnalysis[0]; + + // Student details with performance + const studentDetails = await sql` + SELECT + u.username, + s.passed, + COUNT(sub.id) as questions_answered, + COUNT(CASE WHEN sub.tests_passed = true THEN 1 END) as questions_passed, + BOOL_OR(sub.is_flagged) as is_flagged, + ARRAY_AGG(DISTINCT unnest(sub.flag_reasons)) FILTER (WHERE sub.is_flagged = true) as flag_reasons + FROM sessions s + INNER JOIN users u ON u.id = s.user_id + LEFT JOIN submissions sub ON sub.session_id = s.id + WHERE s.exercise_id = ${exerciseId} + GROUP BY u.username, s.passed + ORDER BY s.passed DESC NULLS LAST, u.username + `; + + // Calculate trajectories + let highPerformers = 0; + let moderatePerformers = 0; + let strugglingStudents = 0; + let atRisk = 0; + + studentDetails.forEach((student: any) => { + const questionsAnswered = Number(student.questions_answered); + const questionsPassed = Number(student.questions_passed); + const passRate = questionsAnswered > 0 ? (questionsPassed / questionsAnswered) * 100 : 0; + const isFlagged = Boolean(student.is_flagged); + + if (passRate > 80) { + highPerformers++; + } else if (passRate >= 50) { + moderatePerformers++; + } else { + strugglingStudents++; + if (isFlagged) { + atRisk++; + } + } + }); + + const reportData = { + overview: { + totalParticipants, + passedCount, + failedCount, + passRate, + }, + questionPerformance: questionPerformance.map((q: any) => ({ + questionIndex: Number(q.question_index), + title: q.title, + totalSubmissions: Number(q.total_submissions), + passedSubmissions: Number(q.passed_submissions), + passRate: Number(q.pass_rate) || 0, + })), + flaggedSubmissions: { + totalFlagged: Number(flagged.total_flagged) || 0, + focusLoss: Number(flagged.focus_loss) || 0, + paste: Number(flagged.paste) || 0, + lowEdits: Number(flagged.low_edits) || 0, + }, + studentDetails: studentDetails.map((s: any) => ({ + username: s.username, + passed: Boolean(s.passed), + questionsAnswered: Number(s.questions_answered), + questionsPassed: Number(s.questions_passed), + isFlagged: Boolean(s.is_flagged), + flagReasons: s.flag_reasons || [], + })), + trajectories: { + highPerformers, + moderatePerformers, + strugglingStudents, + atRisk, + }, + }; + + return NextResponse.json(reportData); + } catch (err) { + console.error('Report generation error:', err); + return NextResponse.json({ error: 'Failed to generate report' }, { status: 500 }); + } +} diff --git a/app/instructor/page.tsx b/app/instructor/page.tsx index e51eb9a..c378062 100644 --- a/app/instructor/page.tsx +++ b/app/instructor/page.tsx @@ -7,7 +7,7 @@ import CreateExercise from './CreateExercise'; import ExercisesTable from './ExercisesTable'; import AnalyticsPanel from './AnalyticsPanel'; import Navbar from '@/app/components/Navbar'; -import { Radio, Users } from 'lucide-react'; +import { Radio, Users, FileText } from 'lucide-react'; import { getAllHistoryResults, type HistorySession } from '@/lib/history-db'; import { formatWAT } from '@/lib/format'; import PastRecodingTable from './PastRecodingTable'; @@ -138,6 +138,13 @@ export default async function InstructorDashboard() { {participantCount} Manage → + + + Reports + + 📊 + View → + {/* Cohort Analytics */} diff --git a/app/instructor/reports/ReportView.tsx b/app/instructor/reports/ReportView.tsx new file mode 100644 index 0000000..3869786 --- /dev/null +++ b/app/instructor/reports/ReportView.tsx @@ -0,0 +1,325 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Download } from 'lucide-react'; + +interface Exercise { + id: string; + title: string; + slug: string; + question_count: number; + enabled: boolean; +} + +interface ReportData { + overview: { + totalParticipants: number; + passedCount: number; + failedCount: number; + passRate: number; + }; + questionPerformance: Array<{ + questionIndex: number; + title: string; + totalSubmissions: number; + passedSubmissions: number; + passRate: number; + }>; + flaggedSubmissions: { + totalFlagged: number; + focusLoss: number; + paste: number; + lowEdits: number; + }; + studentDetails: Array<{ + username: string; + passed: boolean; + questionsAnswered: number; + questionsPassed: number; + isFlagged: boolean; + flagReasons: string[]; + }>; + trajectories: { + highPerformers: number; + moderatePerformers: number; + strugglingStudents: number; + atRisk: number; + }; +} + +export default function ReportView({ exercises }: { exercises: Exercise[] }) { + const [selectedExercise, setSelectedExercise] = useState('7b4102ef-57b6-4e17-b631-16232468f82b'); // Go Reloaded + const [reportData, setReportData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (selectedExercise) { + fetchReportData(selectedExercise); + } + }, [selectedExercise]); + + async function fetchReportData(exerciseId: string) { + setLoading(true); + try { + const res = await fetch(`/api/instructor/reports/${exerciseId}`); + if (res.ok) { + const data = await res.json(); + setReportData(data); + } + } catch (err) { + console.error('Failed to fetch report:', err); + } finally { + setLoading(false); + } + } + + function downloadCSV() { + if (!reportData) return; + + const csvRows = [ + ['Username', 'Status', 'Questions Answered', 'Questions Passed', 'Pass Rate %', 'Flagged', 'Flag Reasons'].join(','), + ...reportData.studentDetails.map(s => [ + s.username, + s.passed ? 'PASSED' : 'FAILED', + s.questionsAnswered, + s.questionsPassed, + s.questionsAnswered > 0 ? ((s.questionsPassed / s.questionsAnswered) * 100).toFixed(1) : '0', + s.isFlagged ? 'Yes' : 'No', + s.flagReasons.join('; ') + ].join(',')) + ]; + + const csvContent = csvRows.join('\n'); + const blob = new Blob([csvContent], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `recoding-report-${new Date().toISOString().split('T')[0]}.csv`; + a.click(); + URL.revokeObjectURL(url); + } + + if (loading) { + return ( +
+

Loading report...

+
+ ); + } + + if (!reportData) { + return ( +
+

No data available

+
+ ); + } + + return ( +
+ {/* Exercise Selector */} +
+
+
+ + +
+ +
+
+ + {/* Overview Metrics */} +
+
+ Executive Summary +
+
+
+
Total Participants
+
{reportData.overview.totalParticipants}
+
+
+
Passed
+
{reportData.overview.passedCount}
+
+
+
Failed
+
{reportData.overview.failedCount}
+
+
+
Pass Rate
+
= 50 ? 'var(--green)' : 'var(--red)' }}> + {reportData.overview.passRate.toFixed(1)}% +
+
+
+
+ + {/* Question Performance */} +
+
+ Performance Analysis - Why They Passed/Failed + Question-Level Breakdown +
+
+ + + + + + + + + + + + {reportData.questionPerformance.map((q) => ( + + + + + + + + ))} + +
Q#QuestionTotalPassedPass Rate
{q.questionIndex + 1}{q.title}{q.totalSubmissions}{q.passedSubmissions}= 50 ? 'var(--green)' : 'var(--red)' }}> + {q.passRate.toFixed(1)}% +
+
+
+ + {/* Trajectories & Recommendations */} +
+
+ Student Trajectories + Predictive Insights +
+
+
+
+
High Performers (>80%)
+
Ready for advanced challenges
+
+
{reportData.trajectories.highPerformers}
+
+ +
+
+
Moderate Performers (50-79%)
+
Need targeted support on specific topics
+
+
{reportData.trajectories.moderatePerformers}
+
+ +
+
+
Struggling Students (<50%)
+
Require intervention and remedial work
+
+
{reportData.trajectories.strugglingStudents}
+
+ +
+
+
At-Risk (Low Score + Flags)
+
Both low performance AND integrity concerns
+
+
{reportData.trajectories.atRisk}
+
+
+
+ + {/* Flagged Submissions */} +
+
+ Quality & Integrity Indicators + Flagged: {reportData.flaggedSubmissions.totalFlagged} +
+
+
+
Focus Loss
+
{reportData.flaggedSubmissions.focusLoss}
+
+
+
Paste Detection
+
{reportData.flaggedSubmissions.paste}
+
+
+
Low Edit Count
+
{reportData.flaggedSubmissions.lowEdits}
+
+
+
+ + {/* Student List */} +
+
+ Individual Student Records + {reportData.studentDetails.length} students +
+
+ + + + + + + + + + + + + {reportData.studentDetails.map((student, i) => { + const passRate = student.questionsAnswered > 0 + ? (student.questionsPassed / student.questionsAnswered) * 100 + : 0; + + return ( + + + + + + + + + ); + })} + +
UsernameStatusQuestions AnsweredQuestions PassedPass RateFlags
{student.username} + + {student.passed ? 'PASSED' : 'FAILED'} + + {student.questionsAnswered}{student.questionsPassed}= 50 ? 'var(--green)' : 'var(--red)' }}> + {passRate.toFixed(1)}% + + {student.isFlagged ? ( + {student.flagReasons.join(', ')} + ) : ( + None + )} +
+
+
+
+ ); +} diff --git a/app/instructor/reports/page.tsx b/app/instructor/reports/page.tsx new file mode 100644 index 0000000..b4adcef --- /dev/null +++ b/app/instructor/reports/page.tsx @@ -0,0 +1,40 @@ +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { redirect } from 'next/navigation'; +import { sql } from '@/lib/db'; +import Navbar from '@/app/components/Navbar'; +import Link from 'next/link'; +import ReportView from './ReportView'; + +export default async function ReportsPage() { + const session = await getServerSession(authOptions); + if (session?.user?.role !== 'instructor') redirect('/login'); + + // Get all exercises for selection + const exercises = await sql` + SELECT id, title, slug, question_count, enabled + FROM exercises + ORDER BY title + `; + + return ( +
+ +
+
+
+ Dashboard + / + Performance Reports +
+
+

Performance Analysis Report

+

June 2026 Recoding Assessment

+
+ + +
+
+
+ ); +} From 60149369405ae951819032bebc5485960cdb44be Mon Sep 17 00:00:00 2001 From: jvcByte Date: Fri, 12 Jun 2026 13:31:05 +0100 Subject: [PATCH 02/22] Fix JSX syntax: escape > and < characters in ReportView --- app/instructor/reports/ReportView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/instructor/reports/ReportView.tsx b/app/instructor/reports/ReportView.tsx index 3869786..1d54b33 100644 --- a/app/instructor/reports/ReportView.tsx +++ b/app/instructor/reports/ReportView.tsx @@ -215,7 +215,7 @@ export default function ReportView({ exercises }: { exercises: Exercise[] }) {
-
High Performers (>80%)
+
High Performers (>80%)
Ready for advanced challenges
{reportData.trajectories.highPerformers}
From 13f5dcf2bb3a2ca11dbb12ff5d83b316331ce268 Mon Sep 17 00:00:00 2001 From: jvcByte Date: Fri, 12 Jun 2026 13:34:28 +0100 Subject: [PATCH 03/22] Fix TypeScript type error in reports page --- app/instructor/reports/page.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/app/instructor/reports/page.tsx b/app/instructor/reports/page.tsx index b4adcef..0f408b8 100644 --- a/app/instructor/reports/page.tsx +++ b/app/instructor/reports/page.tsx @@ -6,17 +6,33 @@ import Navbar from '@/app/components/Navbar'; import Link from 'next/link'; import ReportView from './ReportView'; +interface Exercise { + id: string; + title: string; + slug: string; + question_count: number; + enabled: boolean; +} + export default async function ReportsPage() { const session = await getServerSession(authOptions); if (session?.user?.role !== 'instructor') redirect('/login'); // Get all exercises for selection - const exercises = await sql` + const exercisesRaw = await sql` SELECT id, title, slug, question_count, enabled FROM exercises ORDER BY title `; + const exercises: Exercise[] = exercisesRaw.map((e: any) => ({ + id: e.id as string, + title: e.title as string, + slug: e.slug as string, + question_count: Number(e.question_count), + enabled: Boolean(e.enabled), + })); + return (
From 5022d5ef241b5a29ea5a41b566653d5390af7ed1 Mon Sep 17 00:00:00 2001 From: jvcByte Date: Fri, 12 Jun 2026 13:40:03 +0100 Subject: [PATCH 04/22] Fix API route params handling for Next.js 14 and add debug logging --- app/api/instructor/reports/[exerciseId]/route.ts | 10 ++++++---- app/instructor/reports/ReportView.tsx | 9 ++++++++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/app/api/instructor/reports/[exerciseId]/route.ts b/app/api/instructor/reports/[exerciseId]/route.ts index 7a84f73..ed03e1a 100644 --- a/app/api/instructor/reports/[exerciseId]/route.ts +++ b/app/api/instructor/reports/[exerciseId]/route.ts @@ -5,16 +5,18 @@ import { sql } from '@/lib/db'; export async function GET( req: NextRequest, - { params }: { params: { exerciseId: string } } + context: { params: Promise<{ exerciseId: string }> } ) { const session = await getServerSession(authOptions); if (session?.user?.role !== 'instructor') { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } - const { exerciseId } = params; + const { exerciseId } = await context.params; try { + console.log('[Reports API] Fetching report for exercise:', exerciseId); + // Overview statistics const overviewResult = await sql` SELECT @@ -141,7 +143,7 @@ export async function GET( return NextResponse.json(reportData); } catch (err) { - console.error('Report generation error:', err); - return NextResponse.json({ error: 'Failed to generate report' }, { status: 500 }); + console.error('[Reports API] Error generating report:', err); + return NextResponse.json({ error: 'Failed to generate report', details: String(err) }, { status: 500 }); } } diff --git a/app/instructor/reports/ReportView.tsx b/app/instructor/reports/ReportView.tsx index 1d54b33..f017cdc 100644 --- a/app/instructor/reports/ReportView.tsx +++ b/app/instructor/reports/ReportView.tsx @@ -61,13 +61,20 @@ export default function ReportView({ exercises }: { exercises: Exercise[] }) { async function fetchReportData(exerciseId: string) { setLoading(true); try { + console.log('[Report] Fetching data for exercise:', exerciseId); const res = await fetch(`/api/instructor/reports/${exerciseId}`); + console.log('[Report] Response status:', res.status); + if (res.ok) { const data = await res.json(); + console.log('[Report] Data received:', data); setReportData(data); + } else { + const errorData = await res.json().catch(() => ({ error: 'Unknown error' })); + console.error('[Report] API error:', res.status, errorData); } } catch (err) { - console.error('Failed to fetch report:', err); + console.error('[Report] Failed to fetch report:', err); } finally { setLoading(false); } From c7f3082327640ecc0eccb7a911b9c5113c9747dd Mon Sep 17 00:00:00 2001 From: jvcByte Date: Fri, 12 Jun 2026 13:48:07 +0100 Subject: [PATCH 05/22] Add better error handling and display for reports page --- app/api/instructor/reports/test/route.ts | 14 ++++++++++++++ app/instructor/reports/ReportView.tsx | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 app/api/instructor/reports/test/route.ts diff --git a/app/api/instructor/reports/test/route.ts b/app/api/instructor/reports/test/route.ts new file mode 100644 index 0000000..f3ce153 --- /dev/null +++ b/app/api/instructor/reports/test/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; + +export async function GET() { + const session = await getServerSession(authOptions); + + return NextResponse.json({ + message: 'Reports API is working', + authenticated: !!session, + role: session?.user?.role || null, + timestamp: new Date().toISOString() + }); +} diff --git a/app/instructor/reports/ReportView.tsx b/app/instructor/reports/ReportView.tsx index f017cdc..7f6d6fb 100644 --- a/app/instructor/reports/ReportView.tsx +++ b/app/instructor/reports/ReportView.tsx @@ -51,6 +51,7 @@ export default function ReportView({ exercises }: { exercises: Exercise[] }) { const [selectedExercise, setSelectedExercise] = useState('7b4102ef-57b6-4e17-b631-16232468f82b'); // Go Reloaded const [reportData, setReportData] = useState(null); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); useEffect(() => { if (selectedExercise) { @@ -60,6 +61,7 @@ export default function ReportView({ exercises }: { exercises: Exercise[] }) { async function fetchReportData(exerciseId: string) { setLoading(true); + setError(null); try { console.log('[Report] Fetching data for exercise:', exerciseId); const res = await fetch(`/api/instructor/reports/${exerciseId}`); @@ -72,9 +74,11 @@ export default function ReportView({ exercises }: { exercises: Exercise[] }) { } else { const errorData = await res.json().catch(() => ({ error: 'Unknown error' })); console.error('[Report] API error:', res.status, errorData); + setError(`API Error ${res.status}: ${errorData.error || 'Failed to load report'}`); } } catch (err) { console.error('[Report] Failed to fetch report:', err); + setError(`Network error: ${err instanceof Error ? err.message : 'Unknown error'}`); } finally { setLoading(false); } @@ -114,6 +118,20 @@ export default function ReportView({ exercises }: { exercises: Exercise[] }) { ); } + if (error) { + return ( +
+
+

Error Loading Report

+

{error}

+ +
+
+ ); + } + if (!reportData) { return (
From 5a99c4c0b0dae8dc820cde430cea9acd86cd043f Mon Sep 17 00:00:00 2001 From: jvcByte Date: Fri, 12 Jun 2026 13:59:56 +0100 Subject: [PATCH 06/22] Fix SQL query for flag_reasons aggregation in reports API --- app/api/instructor/reports/[exerciseId]/route.ts | 5 +++-- app/instructor/reports/page.tsx | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/api/instructor/reports/[exerciseId]/route.ts b/app/api/instructor/reports/[exerciseId]/route.ts index ed03e1a..d1a02b9 100644 --- a/app/api/instructor/reports/[exerciseId]/route.ts +++ b/app/api/instructor/reports/[exerciseId]/route.ts @@ -72,12 +72,13 @@ export async function GET( COUNT(sub.id) as questions_answered, COUNT(CASE WHEN sub.tests_passed = true THEN 1 END) as questions_passed, BOOL_OR(sub.is_flagged) as is_flagged, - ARRAY_AGG(DISTINCT unnest(sub.flag_reasons)) FILTER (WHERE sub.is_flagged = true) as flag_reasons + ARRAY_REMOVE(ARRAY_AGG(DISTINCT flag_reason), NULL) as flag_reasons FROM sessions s INNER JOIN users u ON u.id = s.user_id LEFT JOIN submissions sub ON sub.session_id = s.id + LEFT JOIN LATERAL unnest(COALESCE(sub.flag_reasons, ARRAY[]::text[])) AS flag_reason ON true WHERE s.exercise_id = ${exerciseId} - GROUP BY u.username, s.passed + GROUP BY u.username, s.passed, s.id ORDER BY s.passed DESC NULLS LAST, u.username `; diff --git a/app/instructor/reports/page.tsx b/app/instructor/reports/page.tsx index 0f408b8..d9e2a3d 100644 --- a/app/instructor/reports/page.tsx +++ b/app/instructor/reports/page.tsx @@ -4,7 +4,7 @@ import { redirect } from 'next/navigation'; import { sql } from '@/lib/db'; import Navbar from '@/app/components/Navbar'; import Link from 'next/link'; -import ReportView from './ReportView'; +import ReportView from './ReportView.tsx'; interface Exercise { id: string; From 0ab913afdb7fa44e861eb8f5fe96b23bc2f7d9ef Mon Sep 17 00:00:00 2001 From: jvcByte Date: Fri, 12 Jun 2026 14:03:08 +0100 Subject: [PATCH 07/22] Remove .tsx extension from import --- app/instructor/reports/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/instructor/reports/page.tsx b/app/instructor/reports/page.tsx index d9e2a3d..0f408b8 100644 --- a/app/instructor/reports/page.tsx +++ b/app/instructor/reports/page.tsx @@ -4,7 +4,7 @@ import { redirect } from 'next/navigation'; import { sql } from '@/lib/db'; import Navbar from '@/app/components/Navbar'; import Link from 'next/link'; -import ReportView from './ReportView.tsx'; +import ReportView from './ReportView'; interface Exercise { id: string; From 60de8d32c3ab1b8cb2cfb0de74b29826c224e045 Mon Sep 17 00:00:00 2001 From: jvcByte Date: Fri, 12 Jun 2026 14:22:57 +0100 Subject: [PATCH 08/22] Fix flag detection and add failure reason to student report - Fix flag reason matching to use LIKE pattern matching - Add failure reason column showing why student failed - Show pass criteria (5 questions required) - Display flag counts correctly - Improve flag reasons display in student table --- .../instructor/reports/[exerciseId]/route.ts | 61 +++++++++++++++---- app/instructor/reports/ReportView.tsx | 16 ++++- 2 files changed, 61 insertions(+), 16 deletions(-) diff --git a/app/api/instructor/reports/[exerciseId]/route.ts b/app/api/instructor/reports/[exerciseId]/route.ts index d1a02b9..3710d4e 100644 --- a/app/api/instructor/reports/[exerciseId]/route.ts +++ b/app/api/instructor/reports/[exerciseId]/route.ts @@ -54,9 +54,18 @@ export async function GET( const flaggedAnalysis = await sql` SELECT COUNT(DISTINCT sub.id) as total_flagged, - COUNT(DISTINCT CASE WHEN 'focus_loss' = ANY(sub.flag_reasons) THEN sub.id END) as focus_loss, - COUNT(DISTINCT CASE WHEN 'paste' = ANY(sub.flag_reasons) THEN sub.id END) as paste, - COUNT(DISTINCT CASE WHEN 'low_edits' = ANY(sub.flag_reasons) THEN sub.id END) as low_edits + COUNT(DISTINCT CASE WHEN EXISTS ( + SELECT 1 FROM unnest(sub.flag_reasons) AS reason + WHERE reason LIKE 'focus_loss_exceeded:%' + ) THEN sub.id END) as focus_loss, + COUNT(DISTINCT CASE WHEN EXISTS ( + SELECT 1 FROM unnest(sub.flag_reasons) AS reason + WHERE reason LIKE 'paste_detected:%' + ) THEN sub.id END) as paste, + COUNT(DISTINCT CASE WHEN EXISTS ( + SELECT 1 FROM unnest(sub.flag_reasons) AS reason + WHERE reason LIKE 'low_edit_count:%' + ) THEN sub.id END) as low_edits FROM submissions sub WHERE sub.session_id IN (SELECT id FROM sessions WHERE exercise_id = ${exerciseId}) AND sub.is_flagged = true @@ -72,13 +81,16 @@ export async function GET( COUNT(sub.id) as questions_answered, COUNT(CASE WHEN sub.tests_passed = true THEN 1 END) as questions_passed, BOOL_OR(sub.is_flagged) as is_flagged, - ARRAY_REMOVE(ARRAY_AGG(DISTINCT flag_reason), NULL) as flag_reasons + ARRAY_REMOVE(ARRAY_AGG(DISTINCT flag_reason), NULL) as flag_reasons, + e.min_questions_required, + e.flag_fails FROM sessions s INNER JOIN users u ON u.id = s.user_id + INNER JOIN exercises e ON e.id = s.exercise_id LEFT JOIN submissions sub ON sub.session_id = s.id LEFT JOIN LATERAL unnest(COALESCE(sub.flag_reasons, ARRAY[]::text[])) AS flag_reason ON true WHERE s.exercise_id = ${exerciseId} - GROUP BY u.username, s.passed, s.id + GROUP BY u.username, s.passed, s.id, e.min_questions_required, e.flag_fails ORDER BY s.passed DESC NULLS LAST, u.username `; @@ -93,6 +105,8 @@ export async function GET( const questionsPassed = Number(student.questions_passed); const passRate = questionsAnswered > 0 ? (questionsPassed / questionsAnswered) * 100 : 0; const isFlagged = Boolean(student.is_flagged); + const minRequired = Number(student.min_questions_required); + const flagFails = Boolean(student.flag_fails); if (passRate > 80) { highPerformers++; @@ -126,14 +140,35 @@ export async function GET( paste: Number(flagged.paste) || 0, lowEdits: Number(flagged.low_edits) || 0, }, - studentDetails: studentDetails.map((s: any) => ({ - username: s.username, - passed: Boolean(s.passed), - questionsAnswered: Number(s.questions_answered), - questionsPassed: Number(s.questions_passed), - isFlagged: Boolean(s.is_flagged), - flagReasons: s.flag_reasons || [], - })), + studentDetails: studentDetails.map((s: any) => { + const questionsPassed = Number(s.questions_passed); + const minRequired = Number(s.min_questions_required); + const isFlagged = Boolean(s.is_flagged); + const flagFails = Boolean(s.flag_fails); + const passed = Boolean(s.passed); + + // Determine failure reason + let failureReason = ''; + if (!passed) { + if (questionsPassed < minRequired) { + failureReason = `Only passed ${questionsPassed}/${minRequired} required questions`; + } else if (flagFails && isFlagged) { + failureReason = 'Failed due to integrity flags'; + } else { + failureReason = 'Did not meet pass criteria'; + } + } + + return { + username: s.username, + passed, + questionsAnswered: Number(s.questions_answered), + questionsPassed, + isFlagged, + flagReasons: s.flag_reasons || [], + failureReason, + }; + }), trajectories: { highPerformers, moderatePerformers, diff --git a/app/instructor/reports/ReportView.tsx b/app/instructor/reports/ReportView.tsx index 7f6d6fb..f2200bc 100644 --- a/app/instructor/reports/ReportView.tsx +++ b/app/instructor/reports/ReportView.tsx @@ -38,6 +38,7 @@ interface ReportData { questionsPassed: number; isFlagged: boolean; flagReasons: string[]; + failureReason: string; }>; trajectories: { highPerformers: number; @@ -88,7 +89,7 @@ export default function ReportView({ exercises }: { exercises: Exercise[] }) { if (!reportData) return; const csvRows = [ - ['Username', 'Status', 'Questions Answered', 'Questions Passed', 'Pass Rate %', 'Flagged', 'Flag Reasons'].join(','), + ['Username', 'Status', 'Questions Answered', 'Questions Passed', 'Pass Rate %', 'Flagged', 'Flag Reasons', 'Failure Reason'].join(','), ...reportData.studentDetails.map(s => [ s.username, s.passed ? 'PASSED' : 'FAILED', @@ -96,7 +97,8 @@ export default function ReportView({ exercises }: { exercises: Exercise[] }) { s.questionsPassed, s.questionsAnswered > 0 ? ((s.questionsPassed / s.questionsAnswered) * 100).toFixed(1) : '0', s.isFlagged ? 'Yes' : 'No', - s.flagReasons.join('; ') + s.flagReasons.join('; '), + s.failureReason ].join(',')) ]; @@ -310,6 +312,7 @@ export default function ReportView({ exercises }: { exercises: Exercise[] }) { Questions Passed Pass Rate Flags + Failure Reason @@ -333,11 +336,18 @@ export default function ReportView({ exercises }: { exercises: Exercise[] }) { {student.isFlagged ? ( - {student.flagReasons.join(', ')} + {student.flagReasons.length} flag(s) ) : ( None )} + + {!student.passed && student.failureReason ? ( + {student.failureReason} + ) : ( + + )} + ); })} From b2cab875456ae92bd3053f82aa570c4747e172ca Mon Sep 17 00:00:00 2001 From: jvcByte Date: Fri, 12 Jun 2026 14:39:59 +0100 Subject: [PATCH 09/22] Add manual intervention tracking to student report - Track passed_override (manual pass/fail) - Show verdict and verdict_note - Display review_notes from submissions - Highlight manually intervened students with warning icon - Show session ended manually status - Include all intervention details in CSV export - Distinguish between automatic and manual failures --- .../instructor/reports/[exerciseId]/route.ts | 34 +++++++++++++- app/instructor/reports/ReportView.tsx | 47 ++++++++++++++----- 2 files changed, 67 insertions(+), 14 deletions(-) diff --git a/app/api/instructor/reports/[exerciseId]/route.ts b/app/api/instructor/reports/[exerciseId]/route.ts index 3710d4e..0d5eeae 100644 --- a/app/api/instructor/reports/[exerciseId]/route.ts +++ b/app/api/instructor/reports/[exerciseId]/route.ts @@ -78,10 +78,15 @@ export async function GET( SELECT u.username, s.passed, + s.passed_override, + s.closed_at, + s.verdict, + s.verdict_note, COUNT(sub.id) as questions_answered, COUNT(CASE WHEN sub.tests_passed = true THEN 1 END) as questions_passed, BOOL_OR(sub.is_flagged) as is_flagged, ARRAY_REMOVE(ARRAY_AGG(DISTINCT flag_reason), NULL) as flag_reasons, + STRING_AGG(DISTINCT sub.review_note, '; ') FILTER (WHERE sub.review_note IS NOT NULL) as review_notes, e.min_questions_required, e.flag_fails FROM sessions s @@ -90,7 +95,7 @@ export async function GET( LEFT JOIN submissions sub ON sub.session_id = s.id LEFT JOIN LATERAL unnest(COALESCE(sub.flag_reasons, ARRAY[]::text[])) AS flag_reason ON true WHERE s.exercise_id = ${exerciseId} - GROUP BY u.username, s.passed, s.id, e.min_questions_required, e.flag_fails + GROUP BY u.username, s.passed, s.passed_override, s.closed_at, s.verdict, s.verdict_note, s.id, e.min_questions_required, e.flag_fails ORDER BY s.passed DESC NULLS LAST, u.username `; @@ -146,11 +151,26 @@ export async function GET( const isFlagged = Boolean(s.is_flagged); const flagFails = Boolean(s.flag_fails); const passed = Boolean(s.passed); + const passedOverride = s.passed_override !== null ? Boolean(s.passed_override) : null; + const verdict = s.verdict as string | null; + const verdictNote = s.verdict_note as string | null; + const reviewNotes = s.review_notes as string | null; + const closedAt = s.closed_at as string | null; + + // Check for manual interventions + const manuallyEnded = closedAt !== null; + const manualOverride = passedOverride !== null; + const hasVerdict = verdict !== null; + const hasReviewNotes = reviewNotes !== null; // Determine failure reason let failureReason = ''; if (!passed) { - if (questionsPassed < minRequired) { + if (manualOverride) { + failureReason = `Manually overridden to FAIL${verdictNote ? ': ' + verdictNote : ''}`; + } else if (hasVerdict && verdict === 'fail') { + failureReason = `Verdict: ${verdict}${verdictNote ? ' - ' + verdictNote : ''}`; + } else if (questionsPassed < minRequired) { failureReason = `Only passed ${questionsPassed}/${minRequired} required questions`; } else if (flagFails && isFlagged) { failureReason = 'Failed due to integrity flags'; @@ -159,6 +179,13 @@ export async function GET( } } + // Build intervention notes + let interventionNote = ''; + if (manuallyEnded) interventionNote += 'Session ended manually. '; + if (manualOverride) interventionNote += `Pass status overridden. `; + if (hasVerdict) interventionNote += `Verdict: ${verdict}. `; + if (verdictNote) interventionNote += verdictNote; + return { username: s.username, passed, @@ -167,6 +194,9 @@ export async function GET( isFlagged, flagReasons: s.flag_reasons || [], failureReason, + manualIntervention: manualOverride || hasVerdict, + interventionNote: interventionNote.trim() || null, + reviewNotes, }; }), trajectories: { diff --git a/app/instructor/reports/ReportView.tsx b/app/instructor/reports/ReportView.tsx index f2200bc..f659d36 100644 --- a/app/instructor/reports/ReportView.tsx +++ b/app/instructor/reports/ReportView.tsx @@ -39,6 +39,9 @@ interface ReportData { isFlagged: boolean; flagReasons: string[]; failureReason: string; + manualIntervention: boolean; + interventionNote: string | null; + reviewNotes: string | null; }>; trajectories: { highPerformers: number; @@ -89,7 +92,7 @@ export default function ReportView({ exercises }: { exercises: Exercise[] }) { if (!reportData) return; const csvRows = [ - ['Username', 'Status', 'Questions Answered', 'Questions Passed', 'Pass Rate %', 'Flagged', 'Flag Reasons', 'Failure Reason'].join(','), + ['Username', 'Status', 'Questions Answered', 'Questions Passed', 'Pass Rate %', 'Flagged', 'Failure Reason', 'Manual Intervention', 'Intervention Note', 'Review Notes'].join(','), ...reportData.studentDetails.map(s => [ s.username, s.passed ? 'PASSED' : 'FAILED', @@ -97,9 +100,11 @@ export default function ReportView({ exercises }: { exercises: Exercise[] }) { s.questionsPassed, s.questionsAnswered > 0 ? ((s.questionsPassed / s.questionsAnswered) * 100).toFixed(1) : '0', s.isFlagged ? 'Yes' : 'No', - s.flagReasons.join('; '), - s.failureReason - ].join(',')) + s.failureReason, + s.manualIntervention ? 'Yes' : 'No', + s.interventionNote || '', + s.reviewNotes || '' + ].map(field => `"${String(field).replace(/"/g, '""')}"`).join(',')) ]; const csvContent = csvRows.join('\n'); @@ -312,7 +317,7 @@ export default function ReportView({ exercises }: { exercises: Exercise[] }) { Questions Passed Pass Rate Flags - Failure Reason + Failure Reason / Notes @@ -322,8 +327,13 @@ export default function ReportView({ exercises }: { exercises: Exercise[] }) { : 0; return ( - - {student.username} + + + {student.username} + {student.manualIntervention && ( + ⚠️ MANUAL + )} + {student.passed ? 'PASSED' : 'FAILED'} @@ -341,11 +351,24 @@ export default function ReportView({ exercises }: { exercises: Exercise[] }) { None )} - - {!student.passed && student.failureReason ? ( - {student.failureReason} - ) : ( - + + {!student.passed && student.failureReason && ( +
+ {student.failureReason} +
+ )} + {student.interventionNote && ( +
+ {student.interventionNote} +
+ )} + {student.reviewNotes && ( +
+ Review: {student.reviewNotes} +
+ )} + {!student.failureReason && !student.interventionNote && !student.reviewNotes && ( + )} From baa980025ebf980bebfe97a17e34ed2794e742f0 Mon Sep 17 00:00:00 2001 From: jvcByte Date: Fri, 12 Jun 2026 14:53:42 +0100 Subject: [PATCH 10/22] Fix: Count only final submissions, not autosaves - Change from COUNT(sub.id) to COUNT(CASE WHEN sub.is_final = true) - This correctly counts Questions Answered (final submissions only) - Fixes inflated submission counts that included autosaves - Updates both student details and question-level performance --- app/api/instructor/reports/[exerciseId]/route.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/api/instructor/reports/[exerciseId]/route.ts b/app/api/instructor/reports/[exerciseId]/route.ts index 0d5eeae..72ff58b 100644 --- a/app/api/instructor/reports/[exerciseId]/route.ts +++ b/app/api/instructor/reports/[exerciseId]/route.ts @@ -38,9 +38,9 @@ export async function GET( SELECT q.question_index, SUBSTRING(q.text FROM 1 FOR 100) as title, - COUNT(sub.id) as total_submissions, - COUNT(CASE WHEN sub.tests_passed = true THEN 1 END) as passed_submissions, - ROUND(100.0 * COUNT(CASE WHEN sub.tests_passed = true THEN 1 END) / NULLIF(COUNT(sub.id), 0), 1) as pass_rate + COUNT(CASE WHEN sub.is_final = true THEN 1 END) as total_submissions, + COUNT(CASE WHEN sub.is_final = true AND sub.tests_passed = true THEN 1 END) as passed_submissions, + ROUND(100.0 * COUNT(CASE WHEN sub.is_final = true AND sub.tests_passed = true THEN 1 END) / NULLIF(COUNT(CASE WHEN sub.is_final = true THEN 1 END), 0), 1) as pass_rate FROM questions q LEFT JOIN submissions sub ON sub.session_id IN (SELECT id FROM sessions WHERE exercise_id = ${exerciseId}) @@ -82,8 +82,8 @@ export async function GET( s.closed_at, s.verdict, s.verdict_note, - COUNT(sub.id) as questions_answered, - COUNT(CASE WHEN sub.tests_passed = true THEN 1 END) as questions_passed, + COUNT(CASE WHEN sub.is_final = true THEN 1 END) as questions_answered, + COUNT(CASE WHEN sub.is_final = true AND sub.tests_passed = true THEN 1 END) as questions_passed, BOOL_OR(sub.is_flagged) as is_flagged, ARRAY_REMOVE(ARRAY_AGG(DISTINCT flag_reason), NULL) as flag_reasons, STRING_AGG(DISTINCT sub.review_note, '; ') FILTER (WHERE sub.review_note IS NOT NULL) as review_notes, From 7d533994ef7db3cc7357c4a722a480ca657e4ea6 Mon Sep 17 00:00:00 2001 From: jvcByte Date: Fri, 12 Jun 2026 15:00:49 +0100 Subject: [PATCH 11/22] Change 'Questions Answered' to 'Questions Attempted' for clarity - Better reflects that students may submit without completing - Includes submissions with TODO or minimal code - Added clarifying notes in report - More accurate terminology for stakeholders --- app/instructor/reports/ReportView.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/instructor/reports/ReportView.tsx b/app/instructor/reports/ReportView.tsx index f659d36..3bc78d3 100644 --- a/app/instructor/reports/ReportView.tsx +++ b/app/instructor/reports/ReportView.tsx @@ -92,7 +92,7 @@ export default function ReportView({ exercises }: { exercises: Exercise[] }) { if (!reportData) return; const csvRows = [ - ['Username', 'Status', 'Questions Answered', 'Questions Passed', 'Pass Rate %', 'Flagged', 'Failure Reason', 'Manual Intervention', 'Intervention Note', 'Review Notes'].join(','), + ['Username', 'Status', 'Questions Attempted', 'Questions Passed', 'Pass Rate %', 'Flagged', 'Failure Reason', 'Manual Intervention', 'Intervention Note', 'Review Notes'].join(','), ...reportData.studentDetails.map(s => [ s.username, s.passed ? 'PASSED' : 'FAILED', @@ -210,6 +210,9 @@ export default function ReportView({ exercises }: { exercises: Exercise[] }) { Performance Analysis - Why They Passed/Failed Question-Level Breakdown
+
+ Attempts include all final submissions (even with TODO/incomplete code) +
@@ -307,13 +310,16 @@ export default function ReportView({ exercises }: { exercises: Exercise[] }) { Individual Student Records{reportData.studentDetails.length} students +
+ Note: "Questions Attempted" includes all final submissions, even if left with TODO or minimal code +
- + From cadb89644dda1e2e74ffe21ad9a8a7b6a3bafd96 Mon Sep 17 00:00:00 2001 From: jvcByte Date: Fri, 12 Jun 2026 15:17:07 +0100 Subject: [PATCH 12/22] Add participation tracking to Executive Summary - Show total registered participants (all with 'participant' role) - Show how many actually participated (started a session) - Show how many did not participate - Add participation rate percentage - Clarify that pass/fail rates are of those who participated - Provides complete picture of student engagement --- .../instructor/reports/[exerciseId]/route.ts | 22 +++++++++++++--- app/instructor/reports/ReportView.tsx | 25 ++++++++++++++++--- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/app/api/instructor/reports/[exerciseId]/route.ts b/app/api/instructor/reports/[exerciseId]/route.ts index 72ff58b..b954fdc 100644 --- a/app/api/instructor/reports/[exerciseId]/route.ts +++ b/app/api/instructor/reports/[exerciseId]/route.ts @@ -17,10 +17,19 @@ export async function GET( try { console.log('[Reports API] Fetching report for exercise:', exerciseId); + // Get total participant count (all users with role 'participant') + const totalParticipantsResult = await sql` + SELECT COUNT(*) as total_participants + FROM users + WHERE role = 'participant' + `; + + const totalRegistered = Number(totalParticipantsResult[0].total_participants); + // Overview statistics const overviewResult = await sql` SELECT - COUNT(DISTINCT s.user_id) as total_participants, + COUNT(DISTINCT s.user_id) as participants_who_started, COUNT(DISTINCT CASE WHEN s.passed = true THEN s.user_id END) as passed_count, COUNT(DISTINCT CASE WHEN s.passed = false THEN s.user_id END) as failed_count FROM sessions s @@ -28,10 +37,12 @@ export async function GET( `; const overview = overviewResult[0]; - const totalParticipants = Number(overview.total_participants); + const participantsWhoStarted = Number(overview.participants_who_started); const passedCount = Number(overview.passed_count); const failedCount = Number(overview.failed_count); - const passRate = totalParticipants > 0 ? (passedCount / totalParticipants) * 100 : 0; + const didNotParticipate = totalRegistered - participantsWhoStarted; + const passRate = participantsWhoStarted > 0 ? (passedCount / participantsWhoStarted) * 100 : 0; + const participationRate = totalRegistered > 0 ? (participantsWhoStarted / totalRegistered) * 100 : 0; // Question-level performance const questionPerformance = await sql` @@ -127,10 +138,13 @@ export async function GET( const reportData = { overview: { - totalParticipants, + totalRegistered, + participantsWhoStarted, + didNotParticipate, passedCount, failedCount, passRate, + participationRate, }, questionPerformance: questionPerformance.map((q: any) => ({ questionIndex: Number(q.question_index), diff --git a/app/instructor/reports/ReportView.tsx b/app/instructor/reports/ReportView.tsx index 3bc78d3..ad50c93 100644 --- a/app/instructor/reports/ReportView.tsx +++ b/app/instructor/reports/ReportView.tsx @@ -13,10 +13,13 @@ interface Exercise { interface ReportData { overview: { - totalParticipants: number; + totalRegistered: number; + participantsWhoStarted: number; + didNotParticipate: number; passedCount: number; failedCount: number; passRate: number; + participationRate: number; }; questionPerformance: Array<{ questionIndex: number; @@ -182,24 +185,38 @@ export default function ReportView({ exercises }: { exercises: Exercise[] }) {
Executive Summary
-
+
-
Total Participants
-
{reportData.overview.totalParticipants}
+
Total Registered
+
{reportData.overview.totalRegistered}
+
All participants
+
+
+
Participated
+
{reportData.overview.participantsWhoStarted}
+
{reportData.overview.participationRate.toFixed(1)}% participation
+
+
+
Did Not Participate
+
{reportData.overview.didNotParticipate}
+
No session started
Passed
{reportData.overview.passedCount}
+
Of {reportData.overview.participantsWhoStarted} who attempted
Failed
{reportData.overview.failedCount}
+
Of {reportData.overview.participantsWhoStarted} who attempted
Pass Rate
= 50 ? 'var(--green)' : 'var(--red)' }}> {reportData.overview.passRate.toFixed(1)}%
+
Of those who participated
From 3c383447c9b0a814e411a904952ab0f70fd2fe8f Mon Sep 17 00:00:00 2001 From: jvcByte Date: Fri, 12 Jun 2026 15:31:59 +0100 Subject: [PATCH 13/22] Implement verdict UI for instructors to mark sessions - Add API endpoint to set verdict (continue/quit) - Create VerdictButton modal component - Allow instructors to mark sessions as: * 'quit' - Student abandoned/quit the exam * 'continue' - Allow retry/exception for technical issues - Include optional note for explanation - Track who set verdict and when (verdict_by, verdict_at) - Display verdict in student records table - Update report after setting verdict --- .../instructor/reports/[exerciseId]/route.ts | 5 +- .../sessions/[sessionId]/verdict/route.ts | 46 ++++++ app/instructor/reports/ReportView.tsx | 12 ++ app/instructor/reports/VerdictButton.tsx | 143 ++++++++++++++++++ 4 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 app/api/instructor/sessions/[sessionId]/verdict/route.ts create mode 100644 app/instructor/reports/VerdictButton.tsx diff --git a/app/api/instructor/reports/[exerciseId]/route.ts b/app/api/instructor/reports/[exerciseId]/route.ts index b954fdc..0fbbc78 100644 --- a/app/api/instructor/reports/[exerciseId]/route.ts +++ b/app/api/instructor/reports/[exerciseId]/route.ts @@ -87,6 +87,7 @@ export async function GET( // Student details with performance const studentDetails = await sql` SELECT + s.id as session_id, u.username, s.passed, s.passed_override, @@ -106,7 +107,7 @@ export async function GET( LEFT JOIN submissions sub ON sub.session_id = s.id LEFT JOIN LATERAL unnest(COALESCE(sub.flag_reasons, ARRAY[]::text[])) AS flag_reason ON true WHERE s.exercise_id = ${exerciseId} - GROUP BY u.username, s.passed, s.passed_override, s.closed_at, s.verdict, s.verdict_note, s.id, e.min_questions_required, e.flag_fails + GROUP BY s.id, u.username, s.passed, s.passed_override, s.closed_at, s.verdict, s.verdict_note, e.min_questions_required, e.flag_fails ORDER BY s.passed DESC NULLS LAST, u.username `; @@ -201,6 +202,7 @@ export async function GET( if (verdictNote) interventionNote += verdictNote; return { + sessionId: s.session_id as string, username: s.username, passed, questionsAnswered: Number(s.questions_answered), @@ -211,6 +213,7 @@ export async function GET( manualIntervention: manualOverride || hasVerdict, interventionNote: interventionNote.trim() || null, reviewNotes, + verdict: verdict, }; }), trajectories: { diff --git a/app/api/instructor/sessions/[sessionId]/verdict/route.ts b/app/api/instructor/sessions/[sessionId]/verdict/route.ts new file mode 100644 index 0000000..ed7495e --- /dev/null +++ b/app/api/instructor/sessions/[sessionId]/verdict/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { sql } from '@/lib/db'; + +export async function POST( + req: NextRequest, + context: { params: Promise<{ sessionId: string }> } +) { + const session = await getServerSession(authOptions); + if (session?.user?.role !== 'instructor') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { sessionId } = await context.params; + + let body: { verdict: 'continue' | 'quit'; note?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const { verdict, note } = body; + + if (!verdict || !['continue', 'quit'].includes(verdict)) { + return NextResponse.json({ error: 'Verdict must be "continue" or "quit"' }, { status: 400 }); + } + + try { + await sql` + UPDATE sessions + SET + verdict = ${verdict}, + verdict_note = ${note || null}, + verdict_by = ${session.user.id}, + verdict_at = NOW() + WHERE id = ${sessionId} + `; + + return NextResponse.json({ success: true }); + } catch (err) { + console.error('Error setting verdict:', err); + return NextResponse.json({ error: 'Failed to set verdict' }, { status: 500 }); + } +} diff --git a/app/instructor/reports/ReportView.tsx b/app/instructor/reports/ReportView.tsx index ad50c93..c4ed445 100644 --- a/app/instructor/reports/ReportView.tsx +++ b/app/instructor/reports/ReportView.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { Download } from 'lucide-react'; +import VerdictButton from './VerdictButton'; interface Exercise { id: string; @@ -35,6 +36,7 @@ interface ReportData { lowEdits: number; }; studentDetails: Array<{ + sessionId: string; username: string; passed: boolean; questionsAnswered: number; @@ -45,6 +47,7 @@ interface ReportData { manualIntervention: boolean; interventionNote: string | null; reviewNotes: string | null; + verdict: string | null; }>; trajectories: { highPerformers: number; @@ -341,6 +344,7 @@ export default function ReportView({ exercises }: { exercises: Exercise[] }) {
+ @@ -394,6 +398,14 @@ export default function ReportView({ exercises }: { exercises: Exercise[] }) { )} + ); })} diff --git a/app/instructor/reports/VerdictButton.tsx b/app/instructor/reports/VerdictButton.tsx new file mode 100644 index 0000000..e745605 --- /dev/null +++ b/app/instructor/reports/VerdictButton.tsx @@ -0,0 +1,143 @@ +'use client'; + +import { useState } from 'react'; +import { toast } from 'sonner'; +import { Scale } from 'lucide-react'; + +interface VerdictButtonProps { + sessionId: string; + username: string; + currentVerdict: string | null; + onUpdate: () => void; +} + +export default function VerdictButton({ sessionId, username, currentVerdict, onUpdate }: VerdictButtonProps) { + const [showModal, setShowModal] = useState(false); + const [verdict, setVerdict] = useState<'continue' | 'quit'>('quit'); + const [note, setNote] = useState(''); + const [submitting, setSubmitting] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSubmitting(true); + + try { + const res = await fetch(`/api/instructor/sessions/${sessionId}/verdict`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ verdict, note }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to set verdict'); + } + + toast.success(`Verdict set: ${verdict}`); + setShowModal(false); + onUpdate(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to set verdict'); + } finally { + setSubmitting(false); + } + } + + return ( + <> + + + {showModal && ( +
+
+
+ Set Verdict for {username} +
+
+
+ +
+ + +
+

+ {verdict === 'quit' + ? 'Mark this session as abandoned/quit by the student' + : 'Allow student to continue despite issues (e.g., technical problems)'} +

+
+ +
+ +
Username StatusQuestions AnsweredQuestions Attempted Questions Passed Pass Rate FlagsPass Rate Flags Failure Reason / NotesVerdict
+ fetchReportData(selectedExercise)} + /> +