Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
c832807
Add comprehensive performance analysis report for June 2026 recoding
jvcByte Jun 12, 2026
6014936
Fix JSX syntax: escape > and < characters in ReportView
jvcByte Jun 12, 2026
13f5dcf
Fix TypeScript type error in reports page
jvcByte Jun 12, 2026
5022d5e
Fix API route params handling for Next.js 14 and add debug logging
jvcByte Jun 12, 2026
c7f3082
Add better error handling and display for reports page
jvcByte Jun 12, 2026
5a99c4c
Fix SQL query for flag_reasons aggregation in reports API
jvcByte Jun 12, 2026
0ab913a
Remove .tsx extension from import
jvcByte Jun 12, 2026
60de8d3
Fix flag detection and add failure reason to student report
jvcByte Jun 12, 2026
b2cab87
Add manual intervention tracking to student report
jvcByte Jun 12, 2026
baa9800
Fix: Count only final submissions, not autosaves
jvcByte Jun 12, 2026
7d53399
Change 'Questions Answered' to 'Questions Attempted' for clarity
jvcByte Jun 12, 2026
cadb896
Add participation tracking to Executive Summary
jvcByte Jun 12, 2026
3c38344
Implement verdict UI for instructors to mark sessions
jvcByte Jun 12, 2026
2961832
Add non-participants tracking with reason management
jvcByte Jun 12, 2026
f319a84
Add expandable student trajectory lists showing usernames per category
jvcByte Jun 12, 2026
e8e3333
mv verdict to one single dynamic route
jvcByte Jun 12, 2026
4d8d886
Fix routing conflict in sessions API and dynamic trajectory backgrounds
jvcByte Jun 12, 2026
562f301
Fix trajectory username badge visibility on dark theme
jvcByte Jun 12, 2026
c4fa278
Comprehensive CSV export with all sections + print/PDF support
jvcByte Jun 12, 2026
27ca8f7
Generate clean print/PDF report in new tab with no UI chrome
jvcByte Jun 12, 2026
4504ac3
Fix PDF popup blocking and add non-participants to CSV/PDF exports
jvcByte Jun 12, 2026
c1a516a
Fix non-participants text visibility in dark theme
jvcByte Jun 12, 2026
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
92 changes: 92 additions & 0 deletions app/api/instructor/reports/[exerciseId]/non-participants/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
246 changes: 246 additions & 0 deletions app/api/instructor/reports/[exerciseId]/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
14 changes: 14 additions & 0 deletions app/api/instructor/reports/test/route.ts
Original file line number Diff line number Diff line change
@@ -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()
});
}
Loading
Loading