diff --git a/app/api/instructor/reports/[exerciseId]/non-participants/route.ts b/app/api/instructor/reports/[exerciseId]/non-participants/route.ts new file mode 100644 index 0000000..07e8475 --- /dev/null +++ b/app/api/instructor/reports/[exerciseId]/non-participants/route.ts @@ -0,0 +1,92 @@ +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, + context: { params: Promise<{ exerciseId: string }> } +) { + const session = await getServerSession(authOptions); + if (session?.user?.role !== 'instructor') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { exerciseId } = await context.params; + + try { + // Get users who did not participate + const nonParticipants = await sql` + SELECT + u.id, + u.username, + ea.non_participation_reason, + ea.reason_set_at, + ea.reason_set_by + FROM users u + LEFT JOIN exercise_assignments ea ON ea.user_id = u.id AND ea.exercise_id = ${exerciseId} + WHERE u.role = 'participant' + AND NOT EXISTS ( + SELECT 1 FROM sessions s + WHERE s.user_id = u.id AND s.exercise_id = ${exerciseId} + ) + ORDER BY u.username + `; + + return NextResponse.json({ + nonParticipants: nonParticipants.map((np: any) => ({ + userId: np.id, + username: np.username, + reason: np.non_participation_reason, + reasonSetAt: np.reason_set_at, + reasonSetBy: np.reason_set_by, + })) + }); + } catch (err) { + console.error('[Non-participants API] Error:', err); + return NextResponse.json({ error: 'Failed to fetch non-participants' }, { status: 500 }); + } +} + +export async function POST( + req: NextRequest, + context: { params: Promise<{ exerciseId: string }> } +) { + const session = await getServerSession(authOptions); + if (session?.user?.role !== 'instructor') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { exerciseId } = await context.params; + + let body: { userId: string; reason: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const { userId, reason } = body; + + if (!userId || !reason) { + return NextResponse.json({ error: 'userId and reason are required' }, { status: 400 }); + } + + try { + // Insert or update exercise_assignment with reason + await sql` + INSERT INTO exercise_assignments (exercise_id, user_id, non_participation_reason, reason_set_by, reason_set_at) + VALUES (${exerciseId}, ${userId}, ${reason}, ${session.user.id}, NOW()) + ON CONFLICT (exercise_id, user_id) + DO UPDATE SET + non_participation_reason = ${reason}, + reason_set_by = ${session.user.id}, + reason_set_at = NOW() + `; + + return NextResponse.json({ success: true }); + } catch (err) { + console.error('[Non-participants API] Error setting reason:', err); + return NextResponse.json({ error: 'Failed to set reason' }, { status: 500 }); + } +} diff --git a/app/api/instructor/reports/[exerciseId]/route.ts b/app/api/instructor/reports/[exerciseId]/route.ts new file mode 100644 index 0000000..9e6a9d8 --- /dev/null +++ b/app/api/instructor/reports/[exerciseId]/route.ts @@ -0,0 +1,246 @@ +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, + context: { params: Promise<{ exerciseId: string }> } +) { + const session = await getServerSession(authOptions); + if (session?.user?.role !== 'instructor') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { exerciseId } = await context.params; + + 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 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 + WHERE s.exercise_id = ${exerciseId} + `; + + const overview = overviewResult[0]; + const participantsWhoStarted = Number(overview.participants_who_started); + const passedCount = Number(overview.passed_count); + const failedCount = Number(overview.failed_count); + 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` + SELECT + q.question_index, + SUBSTRING(q.text FROM 1 FOR 100) as title, + 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}) + 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 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 + `; + + const flagged = flaggedAnalysis[0]; + + // Student details with performance + const studentDetails = await sql` + SELECT + s.id as session_id, + u.username, + s.passed, + s.passed_override, + s.closed_at, + s.verdict, + s.verdict_note, + 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, + 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 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 + `; + + // Calculate trajectories + let highPerformers = 0; + let moderatePerformers = 0; + let strugglingStudents = 0; + let atRisk = 0; + + const highPerformersList: string[] = []; + const moderatePerformersList: string[] = []; + const strugglingStudentsList: string[] = []; + const atRiskList: string[] = []; + + 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); + const minRequired = Number(student.min_questions_required); + const flagFails = Boolean(student.flag_fails); + const username = student.username as string; + + if (passRate > 80) { + highPerformers++; + highPerformersList.push(username); + } else if (passRate >= 50) { + moderatePerformers++; + moderatePerformersList.push(username); + } else { + strugglingStudents++; + strugglingStudentsList.push(username); + if (isFlagged) { + atRisk++; + atRiskList.push(username); + } + } + }); + + const reportData = { + overview: { + totalRegistered, + participantsWhoStarted, + didNotParticipate, + passedCount, + failedCount, + passRate, + participationRate, + }, + 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) => { + 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); + 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 (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'; + } else { + failureReason = 'Did not meet pass criteria'; + } + } + + // 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 { + sessionId: s.session_id as string, + username: s.username, + passed, + questionsAnswered: Number(s.questions_answered), + questionsPassed, + isFlagged, + flagReasons: s.flag_reasons || [], + failureReason, + manualIntervention: manualOverride || hasVerdict, + interventionNote: interventionNote.trim() || null, + reviewNotes, + verdict: verdict, + }; + }), + trajectories: { + highPerformers, + moderatePerformers, + strugglingStudents, + atRisk, + highPerformersList, + moderatePerformersList, + strugglingStudentsList, + atRiskList, + }, + }; + + return NextResponse.json(reportData); + } catch (err) { + 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/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/api/instructor/sessions/[id]/verdict/route.ts b/app/api/instructor/sessions/[id]/verdict/route.ts new file mode 100644 index 0000000..442ce77 --- /dev/null +++ b/app/api/instructor/sessions/[id]/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<{ id: string }> } +) { + const session = await getServerSession(authOptions); + if (session?.user?.role !== 'instructor') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id: 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/globals.css b/app/globals.css index 99811a7..2ceb9d1 100644 --- a/app/globals.css +++ b/app/globals.css @@ -655,3 +655,21 @@ tbody tr:hover td { color: var(--text); } box-shadow: 0 0 0 3px var(--glow2); } .search-input::placeholder { color: var(--text4); } + +/* ===== PRINT / PDF STYLES ===== */ +@media print { + body { background: white !important; color: black !important; font-size: 11px; } + .navbar, button, [class*="btn"], select { display: none !important; } + .breadcrumb { display: none !important; } + .card { border: 1px solid #ccc !important; break-inside: avoid; margin-bottom: 1rem; box-shadow: none !important; } + .card-header { background: #f5f5f5 !important; color: black !important; } + .badge { border: 1px solid #999 !important; color: black !important; background: #eee !important; } + .badge-green { background: #d1fae5 !important; color: #065f46 !important; border-color: #6ee7b7 !important; } + .badge-red { background: #fee2e2 !important; color: #991b1b !important; border-color: #fca5a5 !important; } + table { border-collapse: collapse; width: 100%; } + th, td { border: 1px solid #ccc !important; padding: 4px 8px !important; color: black !important; background: white !important; } + thead { background: #f5f5f5 !important; } + tr { break-inside: avoid; } + .page-title, .page-sub { color: black !important; } + * { -webkit-print-color-adjust: exact; print-color-adjust: exact; } +} 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/NonParticipantsSection.tsx b/app/instructor/reports/NonParticipantsSection.tsx new file mode 100644 index 0000000..e4d1555 --- /dev/null +++ b/app/instructor/reports/NonParticipantsSection.tsx @@ -0,0 +1,183 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { toast } from 'sonner'; +import { UserX, Edit2 } from 'lucide-react'; + +interface NonParticipant { + userId: string; + username: string; + reason: string | null; + reasonSetAt: string | null; + reasonSetBy: string | null; +} + +export default function NonParticipantsSection({ exerciseId }: { exerciseId: string }) { + const [nonParticipants, setNonParticipants] = useState([]); + const [loading, setLoading] = useState(true); + const [editingUser, setEditingUser] = useState(null); + const [reason, setReason] = useState(''); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + fetchNonParticipants(); + }, [exerciseId]); + + async function fetchNonParticipants() { + setLoading(true); + try { + const res = await fetch(`/api/instructor/reports/${exerciseId}/non-participants`); + if (res.ok) { + const data = await res.json(); + setNonParticipants(data.nonParticipants); + } + } catch (err) { + console.error('Failed to fetch non-participants:', err); + } finally { + setLoading(false); + } + } + + async function handleSaveReason(userId: string) { + if (!reason.trim()) { + toast.error('Please enter a reason'); + return; + } + + setSubmitting(true); + try { + const res = await fetch(`/api/instructor/reports/${exerciseId}/non-participants`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId, reason: reason.trim() }), + }); + + if (!res.ok) { + throw new Error('Failed to save reason'); + } + + toast.success('Reason saved'); + setEditingUser(null); + setReason(''); + fetchNonParticipants(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to save'); + } finally { + setSubmitting(false); + } + } + + function startEditing(np: NonParticipant) { + setEditingUser(np.userId); + setReason(np.reason || ''); + } + + function cancelEditing() { + setEditingUser(null); + setReason(''); + } + + if (loading) { + return ( +
+

Loading...

+
+ ); + } + + if (nonParticipants.length === 0) { + return ( +
+
+ Non-Participants + All participated! +
+

+ All registered participants started a session for this exercise. +

+
+ ); + } + + return ( +
+
+ Non-Participants + {nonParticipants.length} did not participate +
+
+ Students who are registered but never started a session +
+
+ + + + + + + + + + {nonParticipants.map((np) => ( + + + + + + ))} + +
UsernameReason for Non-ParticipationActions
+ + {np.username} + + {editingUser === np.userId ? ( +
+ setReason(e.target.value)} + placeholder="e.g., Sick, Withdrew, No show..." + style={{ flex: 1 }} + autoFocus + /> + + +
+ ) : ( +
+ {np.reason ? ( + {np.reason} + ) : ( + No reason provided + )} +
+ )} +
+ {editingUser !== np.userId && ( + + )} +
+
+
+ ); +} diff --git a/app/instructor/reports/ReportView.tsx b/app/instructor/reports/ReportView.tsx new file mode 100644 index 0000000..9dc3f18 --- /dev/null +++ b/app/instructor/reports/ReportView.tsx @@ -0,0 +1,772 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Download, ChevronDown, ChevronUp } from 'lucide-react'; +import VerdictButton from './VerdictButton'; +import NonParticipantsSection from './NonParticipantsSection'; + +interface Exercise { + id: string; + title: string; + slug: string; + question_count: number; + enabled: boolean; +} + +interface ReportData { + overview: { + totalRegistered: number; + participantsWhoStarted: number; + didNotParticipate: number; + passedCount: number; + failedCount: number; + passRate: number; + participationRate: number; + }; + questionPerformance: Array<{ + questionIndex: number; + title: string; + totalSubmissions: number; + passedSubmissions: number; + passRate: number; + }>; + flaggedSubmissions: { + totalFlagged: number; + focusLoss: number; + paste: number; + lowEdits: number; + }; + studentDetails: Array<{ + sessionId: string; + username: string; + passed: boolean; + questionsAnswered: number; + questionsPassed: number; + isFlagged: boolean; + flagReasons: string[]; + failureReason: string; + manualIntervention: boolean; + interventionNote: string | null; + reviewNotes: string | null; + verdict: string | null; + }>; + trajectories: { + highPerformers: number; + moderatePerformers: number; + strugglingStudents: number; + atRisk: number; + highPerformersList: string[]; + moderatePerformersList: string[]; + strugglingStudentsList: string[]; + atRiskList: string[]; + }; +} + +export default function ReportView({ exercises }: { exercises: Exercise[] }) { + const [selectedExercise, setSelectedExercise] = useState('7b4102ef-57b6-4e17-b631-16232468f82b'); // Go Reloaded + const [reportData, setReportData] = useState(null); + const [nonParticipants, setNonParticipants] = useState>([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expandedTrajectory, setExpandedTrajectory] = useState(null); + + useEffect(() => { + if (selectedExercise) { + fetchReportData(selectedExercise); + } + }, [selectedExercise]); + + async function fetchReportData(exerciseId: string) { + setLoading(true); + setError(null); + try { + const [reportRes, npRes] = await Promise.all([ + fetch(`/api/instructor/reports/${exerciseId}`), + fetch(`/api/instructor/reports/${exerciseId}/non-participants`), + ]); + + if (reportRes.ok) { + setReportData(await reportRes.json()); + } else { + const err = await reportRes.json().catch(() => ({ error: 'Unknown error' })); + setError(`API Error ${reportRes.status}: ${err.error || 'Failed to load report'}`); + } + + if (npRes.ok) { + const npData = await npRes.json(); + setNonParticipants(npData.nonParticipants ?? []); + } + } catch (err) { + setError(`Network error: ${err instanceof Error ? err.message : 'Unknown error'}`); + } finally { + setLoading(false); + } + } + + function downloadCSV() { + if (!reportData) return; + const ex = exercises.find(e => e.id === selectedExercise); + const exerciseTitle = ex?.title ?? 'Exercise'; + const date = new Date().toISOString().split('T')[0]; + const row = (cells: (string | number)[]) => + cells.map(f => `"${String(f).replace(/"/g, '""')}"`).join(','); + + const lines: string[] = []; + + lines.push(row(['PERFORMANCE ANALYSIS REPORT'])); + lines.push(row([`Exercise: ${exerciseTitle}`])); + lines.push(row([`Generated: ${new Date().toLocaleString()}`])); + lines.push(''); + + lines.push(row(['=== EXECUTIVE SUMMARY ==='])); + lines.push(row(['Metric', 'Value'])); + lines.push(row(['Total Registered', reportData.overview.totalRegistered])); + lines.push(row(['Participated', `${reportData.overview.participantsWhoStarted} (${reportData.overview.participationRate.toFixed(1)}%)`])); + lines.push(row(['Did Not Participate', reportData.overview.didNotParticipate])); + lines.push(row(['Passed', reportData.overview.passedCount])); + lines.push(row(['Failed', reportData.overview.failedCount])); + lines.push(row(['Pass Rate', `${reportData.overview.passRate.toFixed(1)}%`])); + lines.push(''); + + lines.push(row(['=== QUESTION-LEVEL PERFORMANCE ==='])); + lines.push(row(['Q#', 'Question', 'Total Attempts', 'Passed', 'Pass Rate'])); + reportData.questionPerformance.forEach(q => { + lines.push(row([q.questionIndex + 1, q.title, q.totalSubmissions, q.passedSubmissions, `${q.passRate.toFixed(1)}%`])); + }); + lines.push(''); + + lines.push(row(['=== INTEGRITY & FLAGS ==='])); + lines.push(row(['Metric', 'Count'])); + lines.push(row(['Total Flagged Submissions', reportData.flaggedSubmissions.totalFlagged])); + lines.push(row(['Focus Loss Events', reportData.flaggedSubmissions.focusLoss])); + lines.push(row(['Paste Detection', reportData.flaggedSubmissions.paste])); + lines.push(row(['Low Edit Count', reportData.flaggedSubmissions.lowEdits])); + lines.push(''); + + lines.push(row(['=== STUDENT TRAJECTORIES ==='])); + lines.push(row(['Category', 'Count', 'Students'])); + lines.push(row(['High Performers (>80%)', reportData.trajectories.highPerformers, reportData.trajectories.highPerformersList.join(', ')])); + lines.push(row(['Moderate Performers (50-79%)', reportData.trajectories.moderatePerformers, reportData.trajectories.moderatePerformersList.join(', ')])); + lines.push(row(['Struggling Students (<50%)', reportData.trajectories.strugglingStudents, reportData.trajectories.strugglingStudentsList.join(', ')])); + lines.push(row(['At-Risk (Low Score + Flags)', reportData.trajectories.atRisk, reportData.trajectories.atRiskList.join(', ')])); + lines.push(''); + + lines.push(row(['=== INDIVIDUAL STUDENT RECORDS ==='])); + lines.push(row(['Username', 'Status', 'Questions Attempted', 'Questions Passed', 'Pass Rate', 'Flagged', 'Flag Count', 'Flag Reasons', 'Failure Reason', 'Manual Intervention', 'Intervention Note', 'Review Notes', 'Verdict'])); + reportData.studentDetails.forEach(s => { + const pr = s.questionsAnswered > 0 ? ((s.questionsPassed / s.questionsAnswered) * 100).toFixed(1) : '0.0'; + lines.push(row([ + s.username, + s.passed ? 'PASSED' : 'FAILED', + s.questionsAnswered, + s.questionsPassed, + `${pr}%`, + s.isFlagged ? 'Yes' : 'No', + s.flagReasons.length, + s.flagReasons.join('; '), + s.failureReason || '', + s.manualIntervention ? 'Yes' : 'No', + s.interventionNote || '', + s.reviewNotes || '', + s.verdict || '', + ])); + }); + lines.push(''); + + lines.push(row(['=== NON-PARTICIPANTS ==='])); + lines.push(row(['Username', 'Reason for Non-Participation'])); + if (nonParticipants.length === 0) { + lines.push(row(['(All registered participants attended)', ''])); + } else { + nonParticipants.forEach(np => { + lines.push(row([np.username, np.reason || 'No reason provided'])); + }); + } + + const blob = new Blob(['\uFEFF' + lines.join('\n')], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `recoding-report-${exerciseTitle.replace(/\s+/g, '-').toLowerCase()}-${date}.csv`; + a.click(); + URL.revokeObjectURL(url); + } + + function printReport() { + if (!reportData) return; + const ex = exercises.find(e => e.id === selectedExercise); + const exerciseTitle = ex?.title ?? 'Exercise'; + + const th = (v: string) => `${v}`; + const td = (v: string | number, style = '') => `${v}`; + + const summaryRows = [ + ['Total Registered', `${reportData.overview.totalRegistered}`], + ['Participated', `${reportData.overview.participantsWhoStarted} (${reportData.overview.participationRate.toFixed(1)}%)`], + ['Did Not Participate', `${reportData.overview.didNotParticipate}`], + ['Passed', `${reportData.overview.passedCount}`], + ['Failed', `${reportData.overview.failedCount}`], + ['Pass Rate', `${reportData.overview.passRate.toFixed(1)}%`], + ].map(([label, val]) => `${label}${val}`).join(''); + + const qRows = reportData.questionPerformance.map(q => ` + ${td(q.questionIndex + 1, 'text-align:center;font-weight:600')} + ${td(q.title)} + ${td(q.totalSubmissions, 'text-align:center')} + ${td(q.passedSubmissions, 'text-align:center;color:#16a34a;font-weight:600')} + ${td(q.passRate.toFixed(1) + '%', `text-align:center;font-weight:700;color:${q.passRate >= 50 ? '#16a34a' : '#dc2626'}`)} + `).join(''); + + const flagRows = [ + ['Total Flagged Submissions', reportData.flaggedSubmissions.totalFlagged, '#dc2626'], + ['Focus Loss Events', reportData.flaggedSubmissions.focusLoss, '#d97706'], + ['Paste Detection', reportData.flaggedSubmissions.paste, '#dc2626'], + ['Low Edit Count', reportData.flaggedSubmissions.lowEdits, '#d97706'], + ].map(([label, val, color]) => `${label}${val}`).join(''); + + const trajRows = [ + { label: 'High Performers (>80%)', count: reportData.trajectories.highPerformers, list: reportData.trajectories.highPerformersList, color: '#16a34a', bg: '#f0fdf4' }, + { label: 'Moderate Performers (50–79%)', count: reportData.trajectories.moderatePerformers, list: reportData.trajectories.moderatePerformersList, color: '#ca8a04', bg: '#fefce8' }, + { label: 'Struggling Students (<50%)', count: reportData.trajectories.strugglingStudents, list: reportData.trajectories.strugglingStudentsList, color: '#dc2626', bg: '#fef2f2' }, + { label: 'At-Risk (Low + Flags)', count: reportData.trajectories.atRisk, list: reportData.trajectories.atRiskList, color: '#dc2626', bg: '#fff1f1' }, + ].map(t => ` + ${t.label} + ${t.count} + ${t.list.length ? t.list.join(', ') : 'None'} + `).join(''); + + const studentRows = reportData.studentDetails.map(s => { + const pr = s.questionsAnswered > 0 ? ((s.questionsPassed / s.questionsAnswered) * 100).toFixed(1) : '0.0'; + const notes = [ + s.failureReason, + s.interventionNote, + s.reviewNotes ? `Review: ${s.reviewNotes}` : '', + ].filter(Boolean).join('
'); + return ` + ${s.username}${s.manualIntervention ? ' ⚠ MANUAL' : ''} + ${s.passed ? 'PASSED' : 'FAILED'} + ${s.questionsAnswered} + ${s.questionsPassed} + ${pr}% + ${s.isFlagged ? `⚑ ${s.flagReasons.length}` : ''} + ${notes || ''} + ${s.verdict || '—'} + `; + }).join(''); + + const npRows = nonParticipants.length === 0 + ? `All registered participants attended` + : nonParticipants.map(np => ` + ${np.username} + ${np.reason || 'No reason provided'} + `).join(''); + + const html = ` + + + + Performance Report – ${exerciseTitle} + + + +

Performance Analysis Report

+
${exerciseTitle} — June 2026 Recoding Assessment
+
Generated on ${new Date().toLocaleString()}  |  Confidential — Instructors Only
+ +

1. Executive Summary

+ ${summaryRows}
+ +

2. Question-Level Performance

+ + ${['Q#','Question','Total Attempts','Passed','Pass Rate'].map(th).join('')} + ${qRows} +
+ +

3. Integrity & Flags

+ ${flagRows}
+ +

4. Student Trajectories

+ + ${['Category','Count','Students'].map(th).join('')} + ${trajRows} +
+ +

5. Individual Student Records (${reportData.studentDetails.length} students)

+ + ${['Username','Status','Attempted','Passed','Rate','Flags','Notes / Failure Reason','Verdict'].map(th).join('')} + ${studentRows} +
+ +

6. Non-Participants (${nonParticipants.length})

+ + ${['Username','Reason for Non-Participation'].map(th).join('')} + ${npRows} +
+ + +