From b788f21d137a1bf5c3d6a2f4f8b71ce898c9c32f Mon Sep 17 00:00:00 2001 From: WAED__03 Date: Wed, 6 May 2026 18:14:03 +0200 Subject: [PATCH] Implement full exam feature workflow --- app/(app)/classes/[classId]/exam/page.tsx | 47 +- .../classes/[classId]/leaderboard/page.tsx | 306 +- app/(app)/classes/[classId]/results/page.tsx | 14 + app/(app)/layout.tsx | 17 +- .../attempts/[attemptId]/answers/route.ts | 35 + .../attempts/[attemptId]/events/route.ts | 40 + .../attempts/[attemptId]/grade/route.ts | 34 + .../attempts/[attemptId]/integrity/route.ts | 39 + .../attempts/[attemptId]/release/route.ts | 32 + .../attempts/[attemptId]/retake/route.ts | 32 + .../attempts/[attemptId]/submit/route.ts | 33 + .../exams/[examId]/attempts/route.ts | 34 + .../[classId]/exams/[examId]/publish/route.ts | 31 + .../classes/[classId]/exams/[examId]/route.ts | 80 + app/api/classes/[classId]/exams/route.ts | 70 + components/sidebar.tsx | 90 +- components/top-bar.tsx | 51 +- features/exam/exam-header.tsx | 52 +- features/exam/exam-lobby.tsx | 98 +- features/exam/exam-lock.test.ts | 20 + features/exam/exam-lock.tsx | 229 + features/exam/exam-results.tsx | 290 +- features/exam/exam-screen.tsx | 319 +- features/exam/manager-detail-state.test.ts | 200 + features/exam/manager-detail-state.ts | 137 + features/exam/manager-exam-screen.tsx | 1610 +++++++ features/exam/question-navigator.tsx | 18 +- features/exam/question-view.tsx | 54 +- features/exam/use-class-exam.ts | 521 +++ features/exam/use-exam-session.ts | 261 +- features/results/class-results-screen.tsx | 483 +++ lib/education/selectors.test.ts | 91 +- lib/education/selectors.ts | 34 + lib/exams/audit.ts | 25 + lib/exams/grading.test.ts | 139 + lib/exams/grading.ts | 115 + lib/exams/http.ts | 69 + lib/exams/integrity.test.ts | 70 + lib/exams/integrity.ts | 65 + lib/exams/service.test.ts | 530 +++ lib/exams/service.ts | 3859 +++++++++++++++++ lib/exams/types.ts | 259 ++ lib/features/feature-registry.test.ts | 15 + lib/features/feature-registry.ts | 18 +- lib/supabase/server.ts | 28 + .../20260503190000_create_exam_system.sql | 482 ++ ...000_fix_exam_attempt_retake_constraint.sql | 9 + 47 files changed, 10498 insertions(+), 587 deletions(-) create mode 100644 app/(app)/classes/[classId]/results/page.tsx create mode 100644 app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/answers/route.ts create mode 100644 app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/events/route.ts create mode 100644 app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/grade/route.ts create mode 100644 app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/integrity/route.ts create mode 100644 app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/release/route.ts create mode 100644 app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/retake/route.ts create mode 100644 app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/submit/route.ts create mode 100644 app/api/classes/[classId]/exams/[examId]/attempts/route.ts create mode 100644 app/api/classes/[classId]/exams/[examId]/publish/route.ts create mode 100644 app/api/classes/[classId]/exams/[examId]/route.ts create mode 100644 app/api/classes/[classId]/exams/route.ts create mode 100644 features/exam/exam-lock.test.ts create mode 100644 features/exam/exam-lock.tsx create mode 100644 features/exam/manager-detail-state.test.ts create mode 100644 features/exam/manager-detail-state.ts create mode 100644 features/exam/manager-exam-screen.tsx create mode 100644 features/exam/use-class-exam.ts create mode 100644 features/results/class-results-screen.tsx create mode 100644 lib/exams/audit.ts create mode 100644 lib/exams/grading.test.ts create mode 100644 lib/exams/grading.ts create mode 100644 lib/exams/http.ts create mode 100644 lib/exams/integrity.test.ts create mode 100644 lib/exams/integrity.ts create mode 100644 lib/exams/service.test.ts create mode 100644 lib/exams/service.ts create mode 100644 lib/exams/types.ts create mode 100644 lib/supabase/server.ts create mode 100644 supabase/migrations/20260503190000_create_exam_system.sql create mode 100644 supabase/migrations/20260505124000_fix_exam_attempt_retake_constraint.sql diff --git a/app/(app)/classes/[classId]/exam/page.tsx b/app/(app)/classes/[classId]/exam/page.tsx index f94905f..1e3a5a5 100644 --- a/app/(app)/classes/[classId]/exam/page.tsx +++ b/app/(app)/classes/[classId]/exam/page.tsx @@ -1,13 +1,14 @@ "use client" import { use } from "react" -import { EXAMS } from "@/lib/mock-data" import { ClassFeatureDisabledFallback, ClassRouteFallback, useClassFeatureRoute, } from "@/features/classes/use-class-route" -import { ExamScreen, NoExamState } from "@/features/exam/exam-screen" +import { ExamScreen } from "@/features/exam/exam-screen" +import { ManagerExamScreen } from "@/features/exam/manager-exam-screen" +import { useClassExam } from "@/features/exam/use-class-exam" export default function ExamPage({ params, @@ -15,25 +16,61 @@ export default function ExamPage({ params: Promise<{ classId: string }> }) { const { classId } = use(params) + const { cls, isLoading, errorMessage, isFeatureDisabled } = useClassFeatureRoute(classId, "exam") - const exam = EXAMS.find((e) => e.classId === classId) + const examApi = useClassExam(classId) + const { + data: exam, + isLoading: examLoading, + isMutating: isSubmitting, + startExam, + saveAnswer, + submitExam, + recordEvent, + } = examApi + + // fallback: class loading / error if (!cls) { return ( ) } + // feature disabled if (isFeatureDisabled) { return ( ) } + // exam loading + if (examLoading) { + return
Loading exam...
+ } + + // no exam available if (!exam) { - return + return
No exam available
+ } + + // main screen + if (exam.canManage) { + return } - return + return ( + + ) } diff --git a/app/(app)/classes/[classId]/leaderboard/page.tsx b/app/(app)/classes/[classId]/leaderboard/page.tsx index d420690..0a9ceb7 100644 --- a/app/(app)/classes/[classId]/leaderboard/page.tsx +++ b/app/(app)/classes/[classId]/leaderboard/page.tsx @@ -1,309 +1,11 @@ -"use client" +import { redirect } from "next/navigation" -import { use } from "react" -import { - getLeaderboardByClass, - getUserById, - LeaderboardEntry, -} from "@/lib/mock-data" -import { - ClassFeatureDisabledFallback, - ClassRouteFallback, - useClassFeatureRoute, -} from "@/features/classes/use-class-route" -import { useApp } from "@/lib/store" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Avatar, AvatarFallback } from "@/components/ui/avatar" -import { Progress } from "@/components/ui/progress" -import { Badge } from "@/components/ui/badge" -import { ChartColumn, Medal, Star, Users } from "lucide-react" -import { cn } from "@/lib/utils" - -const RANK_STYLES = [ - { - bg: "bg-amber-100 dark:bg-amber-900/30", - border: "border-amber-300 dark:border-amber-700", - text: "text-amber-700 dark:text-amber-300", - icon: "text-amber-500", - }, - { - bg: "bg-slate-100 dark:bg-slate-800/50", - border: "border-slate-300 dark:border-slate-600", - text: "text-slate-600 dark:text-slate-300", - icon: "text-slate-400", - }, - { - bg: "bg-orange-100 dark:bg-orange-900/20", - border: "border-orange-300 dark:border-orange-700", - text: "text-orange-700 dark:text-orange-300", - icon: "text-orange-500", - }, -] - -export default function LeaderboardPage({ +export default async function LeaderboardPage({ params, }: { params: Promise<{ classId: string }> }) { - const { classId } = use(params) - const { currentUser } = useApp() - const { cls, isLoading, errorMessage, isFeatureDisabled } = - useClassFeatureRoute(classId, "leaderboard") - const entries = getLeaderboardByClass(classId) - - if (!cls) { - return ( - - ) - } - - if (isFeatureDisabled) { - return ( - - ) - } - - const maxScore = entries[0]?.totalScore ?? 1 - const myEntry = entries.find((e) => e.studentId === currentUser.id) - - const top3 = entries.slice(0, 3) - const rest = entries.slice(3) - - return ( -
- {/* Header */} -
-
-

{cls.name}

-

- {cls.code} · Results · {entries.length} students -

-
-
- - - {cls.semester} - -
-
- - {/* My rank callout */} - {myEntry && currentUser.role === "student" && ( - - -
- -
-
-

- Your Result -

-

- {myEntry.totalScore} points · {myEntry.avgScore}% average - · {myEntry.assignments} assignments -

-
-
-

#{myEntry.rank}

-

- of {entries.length} -

-
-
-
- )} - - {/* Top 3 podium */} - {top3.length >= 2 && ( -
- {/* 2nd */} - - {/* 1st */} - - {/* 3rd */} - {top3[2] && ( - - )} -
- )} - - {/* Full table */} - - - - - Class Results - - - -
- {entries.map((entry) => { - const user = getUserById(entry.studentId) - if (!user) return null - const isMe = entry.studentId === currentUser.id - const rankStyle = RANK_STYLES[entry.rank - 1] - return ( -
- {/* Rank */} -
- {entry.rank <= 3 ? ( - - ) : ( - entry.rank - )} -
- - {/* Avatar */} - - - {user.avatar} - - - - {/* Name + progress */} -
-
-

- {user.name} - {isMe && ( - - (you) - - )} -

-
-
- - - {entry.avgScore}% - -
-
- - {/* Result */} -
-

- {entry.totalScore} -

-

points

-
-
- ) - })} -
-
-
-
- ) -} - -function PodiumCard({ - entry, - rank, - maxScore, - height, - currentUserId, -}: { - entry: LeaderboardEntry - rank: number - maxScore: number - height: string - currentUserId: string -}) { - const user = getUserById(entry.studentId) - if (!user) return null - const isMe = entry.studentId === currentUserId - const style = RANK_STYLES[rank - 1] + const { classId } = await params - return ( -
- - - {user.avatar} - - -
-

- {user.name.split(" ")[0]} -

-

- {entry.totalScore} points -

-
-
-
- - #{rank} -
-
-
- ) + redirect(`/classes/${classId}/results`) } diff --git a/app/(app)/classes/[classId]/results/page.tsx b/app/(app)/classes/[classId]/results/page.tsx new file mode 100644 index 0000000..28c7604 --- /dev/null +++ b/app/(app)/classes/[classId]/results/page.tsx @@ -0,0 +1,14 @@ +"use client" + +import { use } from "react" +import { ClassResultsScreen } from "@/features/results/class-results-screen" + +export default function ResultsPage({ + params, +}: { + params: Promise<{ classId: string }> +}) { + const { classId } = use(params) + + return +} diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 21c9cb4..a398914 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -9,6 +9,7 @@ import { LiveSessionMiniBar, LiveSessionProvider, } from "@/features/session/live-session-provider" +import { ExamLockProvider } from "@/features/exam/exam-lock" import { useApp } from "@/lib/store" function AppShell({ children }: { children: React.ReactNode }) { @@ -71,14 +72,16 @@ function AppShell({ children }: { children: React.ReactNode }) { return ( -
- -
- -
{children}
+ +
+ +
+ +
{children}
+
+
- -
+ ) } diff --git a/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/answers/route.ts b/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/answers/route.ts new file mode 100644 index 0000000..07fa780 --- /dev/null +++ b/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/answers/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server" +import { toExamErrorResponse } from "@/lib/exams/http" +import { parseSaveAnswerInput, saveExamAnswer } from "@/lib/exams/service" +import { requireRouteUser } from "@/lib/api/supabase-route" + +export const runtime = "nodejs" + +type RouteContext = { + params: Promise<{ classId: string; examId: string; attemptId: string }> +} + +export async function PATCH(request: Request, context: RouteContext) { + const { classId, examId, attemptId } = await context.params + + try { + const { user, supabase, error } = await requireRouteUser(request) + + if (error || !user || !supabase) { + return NextResponse.json({ error: error }, { status: 401 }) + } + + const body = parseSaveAnswerInput(await request.json().catch(() => null)) + const payload = await saveExamAnswer({ + authSupabase: supabase, + classId, + examId, + attemptId, + userId: user.id, + body, + }) + return NextResponse.json(payload) + } catch (routeError) { + return toExamErrorResponse(routeError) + } +} diff --git a/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/events/route.ts b/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/events/route.ts new file mode 100644 index 0000000..4a0ee50 --- /dev/null +++ b/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/events/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server" +import { toExamErrorResponse } from "@/lib/exams/http" +import { + parseIntegrityEventInput, + recordExamIntegrityEvent, +} from "@/lib/exams/service" +import { requireRouteUser } from "@/lib/api/supabase-route" + +export const runtime = "nodejs" + +type RouteContext = { + params: Promise<{ classId: string; examId: string; attemptId: string }> +} + +export async function POST(request: Request, context: RouteContext) { + const { classId, examId, attemptId } = await context.params + + try { + const { user, supabase, error } = await requireRouteUser(request) + + if (error || !user || !supabase) { + return NextResponse.json({ error: error }, { status: 401 }) + } + + const body = parseIntegrityEventInput( + await request.json().catch(() => null), + ) + const payload = await recordExamIntegrityEvent({ + authSupabase: supabase, + classId, + examId, + attemptId, + userId: user.id, + body, + }) + return NextResponse.json(payload) + } catch (routeError) { + return toExamErrorResponse(routeError) + } +} diff --git a/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/grade/route.ts b/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/grade/route.ts new file mode 100644 index 0000000..e05fa29 --- /dev/null +++ b/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/grade/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server" +import { toExamErrorResponse } from "@/lib/exams/http" +import { gradeExamAttempt, parseGradeAttemptInput } from "@/lib/exams/service" +import { requireRouteUser } from "@/lib/api/supabase-route" + +export const runtime = "nodejs" + +type RouteContext = { + params: Promise<{ classId: string; examId: string; attemptId: string }> +} + +export async function POST(request: Request, context: RouteContext) { + const { classId, examId, attemptId } = await context.params + const { user, supabase, error } = await requireRouteUser(request) + + if (error || !user || !supabase) { + return NextResponse.json({ error: error }, { status: 401 }) + } + + try { + const body = parseGradeAttemptInput(await request.json().catch(() => null)) + const payload = await gradeExamAttempt({ + authSupabase: supabase, + classId, + examId, + attemptId, + userId: user.id, + body, + }) + return NextResponse.json({ exam: payload }) + } catch (routeError) { + return toExamErrorResponse(routeError) + } +} diff --git a/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/integrity/route.ts b/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/integrity/route.ts new file mode 100644 index 0000000..0ffafa1 --- /dev/null +++ b/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/integrity/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server" +import { toExamErrorResponse } from "@/lib/exams/http" +import { + applyExamIntegrityAction, + parseIntegrityActionInput, +} from "@/lib/exams/service" +import { requireRouteUser } from "@/lib/api/supabase-route" + +export const runtime = "nodejs" + +type RouteContext = { + params: Promise<{ classId: string; examId: string; attemptId: string }> +} + +export async function POST(request: Request, context: RouteContext) { + const { classId, examId, attemptId } = await context.params + const { user, supabase, error } = await requireRouteUser(request) + + if (error || !user || !supabase) { + return NextResponse.json({ error: error }, { status: 401 }) + } + + try { + const body = parseIntegrityActionInput( + await request.json().catch(() => null), + ) + const payload = await applyExamIntegrityAction({ + authSupabase: supabase, + classId, + examId, + attemptId, + userId: user.id, + body, + }) + return NextResponse.json({ exam: payload }) + } catch (routeError) { + return toExamErrorResponse(routeError) + } +} diff --git a/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/release/route.ts b/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/release/route.ts new file mode 100644 index 0000000..60cca34 --- /dev/null +++ b/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/release/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server" +import { toExamErrorResponse } from "@/lib/exams/http" +import { releaseExamAttempt } from "@/lib/exams/service" +import { requireRouteUser } from "@/lib/api/supabase-route" + +export const runtime = "nodejs" + +type RouteContext = { + params: Promise<{ classId: string; examId: string; attemptId: string }> +} + +export async function POST(request: Request, context: RouteContext) { + const { classId, examId, attemptId } = await context.params + const { user, supabase, error } = await requireRouteUser(request) + + if (error || !user || !supabase) { + return NextResponse.json({ error: error }, { status: 401 }) + } + + try { + const payload = await releaseExamAttempt({ + authSupabase: supabase, + classId, + examId, + attemptId, + userId: user.id, + }) + return NextResponse.json({ exam: payload }) + } catch (routeError) { + return toExamErrorResponse(routeError) + } +} diff --git a/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/retake/route.ts b/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/retake/route.ts new file mode 100644 index 0000000..3d0a956 --- /dev/null +++ b/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/retake/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server" +import { requireRouteUser } from "@/lib/api/supabase-route" +import { toExamErrorResponse } from "@/lib/exams/http" +import { grantExamRetake } from "@/lib/exams/service" + +export const runtime = "nodejs" + +type RouteContext = { + params: Promise<{ classId: string; examId: string; attemptId: string }> +} + +export async function POST(request: Request, context: RouteContext) { + const { classId, examId, attemptId } = await context.params + const { user, supabase, error } = await requireRouteUser(request) + + if (error || !user || !supabase) { + return NextResponse.json({ error: error }, { status: 401 }) + } + + try { + const payload = await grantExamRetake({ + authSupabase: supabase, + classId, + examId, + attemptId, + userId: user.id, + }) + return NextResponse.json({ exam: payload }) + } catch (routeError) { + return toExamErrorResponse(routeError) + } +} diff --git a/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/submit/route.ts b/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/submit/route.ts new file mode 100644 index 0000000..433cd09 --- /dev/null +++ b/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/submit/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server" +import { toExamErrorResponse } from "@/lib/exams/http" +import { submitExamAttempt } from "@/lib/exams/service" +import { requireRouteUser } from "@/lib/api/supabase-route" + +export const runtime = "nodejs" + +type RouteContext = { + params: Promise<{ classId: string; examId: string; attemptId: string }> +} + +export async function POST(request: Request, context: RouteContext) { + const { classId, examId, attemptId } = await context.params + + try { + const { user, supabase, error } = await requireRouteUser(request) + + if (error || !user || !supabase) { + return NextResponse.json({ error: error }, { status: 401 }) + } + + await submitExamAttempt({ + authSupabase: supabase, + classId, + examId, + attemptId, + userId: user.id, + }) + return NextResponse.json({ ok: true }) + } catch (routeError) { + return toExamErrorResponse(routeError) + } +} diff --git a/app/api/classes/[classId]/exams/[examId]/attempts/route.ts b/app/api/classes/[classId]/exams/[examId]/attempts/route.ts new file mode 100644 index 0000000..2ca174e --- /dev/null +++ b/app/api/classes/[classId]/exams/[examId]/attempts/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server" +import { toExamErrorResponse } from "@/lib/exams/http" +import { parseStartAttemptInput, startExamAttempt } from "@/lib/exams/service" +import { requireRouteUser } from "@/lib/api/supabase-route" + +export const runtime = "nodejs" + +type RouteContext = { + params: Promise<{ classId: string; examId: string }> +} + +export async function POST(request: Request, context: RouteContext) { + const { classId, examId } = await context.params + + try { + const { user, supabase, error } = await requireRouteUser(request) + + if (error || !user || !supabase) { + return NextResponse.json({ error: error }, { status: 401 }) + } + + const body = parseStartAttemptInput(await request.json().catch(() => null)) + const payload = await startExamAttempt({ + authSupabase: supabase, + classId, + examId, + userId: user.id, + body, + }) + return NextResponse.json({ activeExam: payload }) + } catch (routeError) { + return toExamErrorResponse(routeError) + } +} diff --git a/app/api/classes/[classId]/exams/[examId]/publish/route.ts b/app/api/classes/[classId]/exams/[examId]/publish/route.ts new file mode 100644 index 0000000..3baf195 --- /dev/null +++ b/app/api/classes/[classId]/exams/[examId]/publish/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server" +import { toExamErrorResponse } from "@/lib/exams/http" +import { publishExam } from "@/lib/exams/service" +import { requireRouteUser } from "@/lib/api/supabase-route" + +export const runtime = "nodejs" + +type RouteContext = { + params: Promise<{ classId: string; examId: string }> +} + +export async function POST(request: Request, context: RouteContext) { + const { classId, examId } = await context.params + const { user, supabase, error } = await requireRouteUser(request) + + if (error || !user || !supabase) { + return NextResponse.json({ error: error }, { status: 401 }) + } + + try { + const payload = await publishExam({ + authSupabase: supabase, + classId, + examId, + userId: user.id, + }) + return NextResponse.json({ exam: payload }) + } catch (routeError) { + return toExamErrorResponse(routeError) + } +} diff --git a/app/api/classes/[classId]/exams/[examId]/route.ts b/app/api/classes/[classId]/exams/[examId]/route.ts new file mode 100644 index 0000000..1ffee76 --- /dev/null +++ b/app/api/classes/[classId]/exams/[examId]/route.ts @@ -0,0 +1,80 @@ +import { NextResponse } from "next/server" +import { toExamErrorResponse } from "@/lib/exams/http" +import { + deleteExam, + loadManagerExamDetail, + parseUpsertExamInput, + updateExam, +} from "@/lib/exams/service" +import { requireRouteUser } from "@/lib/api/supabase-route" + +export const runtime = "nodejs" + +type RouteContext = { + params: Promise<{ classId: string; examId: string }> +} + +export async function GET(request: Request, context: RouteContext) { + const { classId, examId } = await context.params + const { user, supabase, error } = await requireRouteUser(request) + + if (error || !user || !supabase) { + return NextResponse.json({ error: error }, { status: 401 }) + } + + try { + const payload = await loadManagerExamDetail({ + authSupabase: supabase, + classId, + examId, + userId: user.id, + }) + return NextResponse.json({ exam: payload }) + } catch (routeError) { + return toExamErrorResponse(routeError) + } +} + +export async function PATCH(request: Request, context: RouteContext) { + const { classId, examId } = await context.params + const { user, supabase, error } = await requireRouteUser(request) + + if (error || !user || !supabase) { + return NextResponse.json({ error: error }, { status: 401 }) + } + + try { + const body = parseUpsertExamInput(await request.json().catch(() => null)) + const payload = await updateExam({ + authSupabase: supabase, + classId, + examId, + userId: user.id, + body, + }) + return NextResponse.json({ exam: payload }) + } catch (routeError) { + return toExamErrorResponse(routeError) + } +} + +export async function DELETE(request: Request, context: RouteContext) { + const { classId, examId } = await context.params + const { user, supabase, error } = await requireRouteUser(request) + + if (error || !user || !supabase) { + return NextResponse.json({ error: error }, { status: 401 }) + } + + try { + const payload = await deleteExam({ + authSupabase: supabase, + classId, + examId, + userId: user.id, + }) + return NextResponse.json(payload) + } catch (routeError) { + return toExamErrorResponse(routeError) + } +} diff --git a/app/api/classes/[classId]/exams/route.ts b/app/api/classes/[classId]/exams/route.ts new file mode 100644 index 0000000..244037e --- /dev/null +++ b/app/api/classes/[classId]/exams/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from "next/server" +import { toExamErrorResponse } from "@/lib/exams/http" +import { + createExam, + loadClassExamApiData, + loadManagerExamDetail, + parseUpsertExamInput, +} from "@/lib/exams/service" +import { requireRouteUser } from "@/lib/api/supabase-route" + +export const runtime = "nodejs" + +type RouteContext = { + params: Promise<{ classId: string }> +} + +export async function GET(request: Request, context: RouteContext) { + const { classId } = await context.params + const { user, supabase, error } = await requireRouteUser(request) + + if (error || !user || !supabase) { + return NextResponse.json({ error: error }, { status: 401 }) + } + + try { + const requestUrl = new URL(request.url) + const detailExamId = requestUrl.searchParams.get("detailExamId") + + if (detailExamId) { + const payload = await loadManagerExamDetail({ + authSupabase: supabase, + classId, + examId: detailExamId, + userId: user.id, + }) + return NextResponse.json({ exam: payload }) + } + + const payload = await loadClassExamApiData({ + authSupabase: supabase, + classId, + userId: user.id, + }) + return NextResponse.json(payload) + } catch (routeError) { + return toExamErrorResponse(routeError) + } +} + +export async function POST(request: Request, context: RouteContext) { + const { classId } = await context.params + const { user, supabase, error } = await requireRouteUser(request) + + if (error || !user || !supabase) { + return NextResponse.json({ error: error }, { status: 401 }) + } + + try { + const body = parseUpsertExamInput(await request.json().catch(() => null)) + const payload = await createExam({ + authSupabase: supabase, + classId, + userId: user.id, + body, + }) + return NextResponse.json({ exam: payload }) + } catch (routeError) { + return toExamErrorResponse(routeError) + } +} diff --git a/components/sidebar.tsx b/components/sidebar.tsx index 794225c..eaf1cc4 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -2,6 +2,7 @@ import Link from "next/link" import { usePathname } from "next/navigation" +import type { ReactNode } from "react" import { BookOpen, GraduationCap, @@ -9,6 +10,7 @@ import { ChevronRight, } from "lucide-react" import { cn } from "@/lib/utils" +import { useExamLock } from "@/features/exam/exam-lock" import { useApp } from "@/lib/store" import { getClassesForUser } from "@/lib/education/classes" import { @@ -55,6 +57,7 @@ export function Sidebar({ collapsed, setCollapsed }: SidebarProps) { featureDefinitions, organizationClasses, } = useApp() + const { canNavigateToPath, isLocked, lock } = useExamLock() const isTeacher = currentUser.role === "teacher" const isAdmin = currentUser.role === "admin" @@ -99,11 +102,14 @@ export function Sidebar({ collapsed, setCollapsed }: SidebarProps) { ) : ( <> - @@ -113,7 +119,7 @@ export function Sidebar({ collapsed, setCollapsed }: SidebarProps) { Eduverse - +
) diff --git a/features/exam/exam-lobby.tsx b/features/exam/exam-lobby.tsx index a48e430..8cef005 100644 --- a/features/exam/exam-lobby.tsx +++ b/features/exam/exam-lobby.tsx @@ -4,17 +4,40 @@ import { AlertCircle, BookOpen } from "lucide-react" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" import { cn } from "@/lib/utils" -import type { Class, Exam } from "@/lib/mock-data" export function ExamLobby({ - exam, - cls, + title, + className, + classCode, + status, + questionCount, + durationMinutes, + totalPoints, + requiresPasscode, + startBlockedReason, + passcode, + onPasscodeChange, onStart, + disabled, + actionLabel, }: { - exam: Exam - cls: Pick + title: string + className: string + classCode: string + status: "upcoming" | "live" | "ended" + questionCount: number | null + durationMinutes: number + totalPoints: number + requiresPasscode: boolean + startBlockedReason: string | null + passcode: string + onPasscodeChange: (value: string) => void onStart: () => void + disabled: boolean + actionLabel: string }) { return (
@@ -26,43 +49,43 @@ export function ExamLobby({ variant="secondary" className={cn( "mb-2", - exam.status === "live" && + status === "live" && "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300", - exam.status === "upcoming" && + status === "upcoming" && "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300", + status === "ended" && + "bg-slate-100 text-slate-700 dark:bg-slate-900/40 dark:text-slate-300", )} > - {exam.status === "live" + {status === "live" ? "In Progress" - : exam.status === "upcoming" - ? "Upcoming" + : status === "upcoming" + ? "Scheduled" : "Ended"}

- {exam.title} + {title}

- {cls.name} · {cls.code} + {className} · {classCode}

- {exam.questions.length} + {questionCount ?? "?"}

Questions

- {exam.durationMinutes} + {durationMinutes}

Minutes

-

- {exam.totalPoints} -

+

{totalPoints}

Total pts

@@ -73,9 +96,11 @@ export function ExamLobby({

{[ "Once started, the timer cannot be paused.", - "Code questions include starter code - edit as needed.", - "All answers are auto-saved as you type.", - "Submit before time runs out or it submits automatically.", + "Questions can include multiple choice and short answers.", + "Answers are auto-saved through the backend.", + "Submitting ends the attempt immediately.", + "You must enter the teacher's passcode before the exam can start.", + "Fullscreen exam mode is required. Leaving fullscreen or switching tabs is recorded for the teacher.", ].map((note) => (
@@ -83,13 +108,42 @@ export function ExamLobby({
))} + + {requiresPasscode && status === "live" && ( +
+ + onPasscodeChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && !disabled) { + event.preventDefault() + onStart() + } + }} + placeholder="Enter exam passcode" + autoFocus + minLength={4} + autoComplete="one-time-code" + /> +
+ )} + + {startBlockedReason ? ( +
+ {startBlockedReason} +
+ ) : null} + ) diff --git a/features/exam/exam-lock.test.ts b/features/exam/exam-lock.test.ts new file mode 100644 index 0000000..70f88d5 --- /dev/null +++ b/features/exam/exam-lock.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test" +import { isPathAllowedUnderExamLock } from "@/features/exam/exam-lock" + +describe("isPathAllowedUnderExamLock", () => { + test("allows staying on the exam route during a locked attempt", () => { + expect( + isPathAllowedUnderExamLock("/classes/class-1/exam", { + examRoute: "/classes/class-1/exam", + }), + ).toEqual(true) + }) + + test("blocks navigation to other class sections during a locked attempt", () => { + expect( + isPathAllowedUnderExamLock("/classes/class-1/assignments", { + examRoute: "/classes/class-1/exam", + }), + ).toEqual(false) + }) +}) diff --git a/features/exam/exam-lock.tsx b/features/exam/exam-lock.tsx new file mode 100644 index 0000000..f869264 --- /dev/null +++ b/features/exam/exam-lock.tsx @@ -0,0 +1,229 @@ +"use client" + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react" +import { usePathname, useRouter } from "next/navigation" +import type { ClassExamApiDto } from "@/lib/exams/types" + +type ExamLockState = { + classId: string + examId: string + attemptId: string + examTitle: string + examRoute: string +} + +type ExamLockContextValue = { + lock: ExamLockState | null + isLocked: boolean + setExamLock: (lock: ExamLockState | null) => void + canNavigateToPath: (path: string) => boolean +} + +const STORAGE_KEY = "eduverse.exam-lock" + +const ExamLockContext = createContext(null) + +export function ExamLockProvider({ children }: { children: ReactNode }) { + const pathname = usePathname() + const router = useRouter() + const [lock, setLockState] = useState(null) + const [isLockConfirmed, setIsLockConfirmed] = useState(false) + const lastLeavePathRef = useRef(null) + + const setExamLock = useCallback((nextLock: ExamLockState | null) => { + setLockState(nextLock) + setIsLockConfirmed(Boolean(nextLock)) + + if (typeof window === "undefined") return + + if (!nextLock) { + window.sessionStorage.removeItem(STORAGE_KEY) + return + } + + window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(nextLock)) + }, []) + + useEffect(() => { + if (typeof window === "undefined") return + + const raw = window.sessionStorage.getItem(STORAGE_KEY) + if (!raw) return + + try { + setLockState(JSON.parse(raw) as ExamLockState) + setIsLockConfirmed(false) + } catch { + window.sessionStorage.removeItem(STORAGE_KEY) + } + }, []) + + useEffect(() => { + const classIdToCheck = lock?.classId ?? extractClassId(pathname) + if (!classIdToCheck) return + const nextClassId = classIdToCheck + + let cancelled = false + + async function syncLockFromServer() { + try { + const response = await fetch( + `/api/classes/${encodeURIComponent(nextClassId)}/exams`, + ) + const payload = (await response.json().catch(() => null)) as + | (ClassExamApiDto & { error?: string }) + | { error?: string } + | null + + if ( + cancelled || + !response.ok || + !payload || + "error" in payload || + !isClassExamApiPayload(payload) + ) { + return + } + + if (payload.canManage) { + if (lock?.classId === nextClassId) { + setLockState(null) + setIsLockConfirmed(false) + if (typeof window !== "undefined") { + window.sessionStorage.removeItem(STORAGE_KEY) + } + } + return + } + + const activeExam = payload.student.activeExam + const nextLock = + activeExam?.attempt === null || activeExam?.attempt === undefined + ? null + : { + classId: activeExam.classId, + examId: activeExam.id, + attemptId: activeExam.attempt.id, + examTitle: activeExam.title, + examRoute: `/classes/${activeExam.classId}/exam`, + } + + setLockState(nextLock) + setIsLockConfirmed(Boolean(nextLock)) + + if (typeof window === "undefined") return + + if (!nextLock) { + window.sessionStorage.removeItem(STORAGE_KEY) + return + } + + window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(nextLock)) + } catch {} + } + + void syncLockFromServer() + + return () => { + cancelled = true + } + }, [lock?.classId, pathname]) + + useEffect(() => { + if (!lock || !isLockConfirmed) return + if (isPathAllowedUnderExamLock(pathname, lock)) { + lastLeavePathRef.current = null + return + } + + if (lastLeavePathRef.current !== pathname) { + lastLeavePathRef.current = pathname + void recordRouteLeaveAttempt(lock, pathname) + } + + router.replace(lock.examRoute) + }, [isLockConfirmed, lock, pathname, router]) + + const value = useMemo( + () => ({ + lock, + isLocked: Boolean(lock && isLockConfirmed), + setExamLock, + canNavigateToPath: (path: string) => + !lock || !isLockConfirmed || isPathAllowedUnderExamLock(path, lock), + }), + [isLockConfirmed, lock, setExamLock], + ) + + return ( + + {children} + + ) +} + +export function useExamLock() { + const context = useContext(ExamLockContext) + if (!context) { + throw new Error("useExamLock must be used within an ExamLockProvider.") + } + + return context +} + +function extractClassId(pathname: string) { + const match = pathname.match(/^\/classes\/([^/]+)/) + return match?.[1] ?? null +} + +function isClassExamApiPayload(value: unknown): value is ClassExamApiDto { + return ( + typeof value === "object" && + value !== null && + "canManage" in value && + typeof value.canManage === "boolean" + ) +} + +export function isPathAllowedUnderExamLock( + pathname: string, + lock: Pick, +) { + return ( + pathname === lock.examRoute || pathname.startsWith(`${lock.examRoute}/`) + ) +} + +async function recordRouteLeaveAttempt( + lock: ExamLockState, + attemptedPath: string, +) { + try { + await fetch( + `/api/classes/${encodeURIComponent( + lock.classId, + )}/exams/${encodeURIComponent(lock.examId)}/attempts/${encodeURIComponent( + lock.attemptId, + )}/events`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + eventType: "route_leave_attempt", + payload: { + attemptedPath, + }, + }), + }, + ) + } catch {} +} diff --git a/features/exam/exam-results.tsx b/features/exam/exam-results.tsx index 6a37c72..9b29233 100644 --- a/features/exam/exam-results.tsx +++ b/features/exam/exam-results.tsx @@ -1,92 +1,146 @@ "use client" -import { CheckCircle2 } from "lucide-react" +import { format } from "date-fns" +import { AlertCircle, CheckCircle2, Clock3 } from "lucide-react" import { Badge } from "@/components/ui/badge" import { Card, CardContent } from "@/components/ui/card" +import type { + ReleasedExamQuestionResultDto, + ReleasedExamResultDto, +} from "@/lib/exams/types" import { cn } from "@/lib/utils" -import type { Exam } from "@/lib/mock-data" - -export function ExamResults({ - exam, - answers, -}: { - exam: Exam - answers: Record -}) { - const mcqScore = exam.questions - .filter((question) => question.type === "mcq") - .reduce( - (sum, question) => - answers[question.id] === question.correctIndex - ? sum + question.points - : sum, - 0, - ) - const pendingReview = exam.questions - .filter((question) => question.type !== "mcq") - .reduce((sum, question) => sum + question.points, 0) - const percentage = Math.round((mcqScore / exam.totalPoints) * 100) + +export function ExamResults({ result }: { result: ReleasedExamResultDto }) { + const percentage = + result.isReleased && result.totalPoints > 0 && result.totalScore !== null + ? Math.round((result.totalScore / result.totalPoints) * 100) + : null return (
-
- +
+ {result.isReleased ? ( + + ) : ( + + )}
-

Exam Submitted!

+

{result.title}

- Your exam has been submitted successfully. Auto-graded results are - shown below. + {result.isReleased + ? "Your released exam result is shown below." + : result.needsManualReview + ? "Your attempt was submitted successfully. Teacher review and result release are still pending." + : "Your attempt was submitted successfully. Scores stay hidden until results are released."}

+
+ + {formatAttemptStatus(result.status)} + + + {formatIntegrityStatus(result.integrityStatus)} + +
-

- {mcqScore} +

+ {result.totalScore ?? "Pending"} +

+

+ {result.isReleased ? "Score" : "Score status"}

-

MCQ Score

-

- {pendingReview} -

-

- Pending Review +

+ {result.totalPoints}

+

Total pts

-

{percentage}%

-

MCQ %

+

+ {percentage === null ? "-" : `${percentage}%`} +

+

Percent

+
+
+
+ + + +
+

+ Submitted +

+

+ {result.submittedAt + ? format(new Date(result.submittedAt), "MMM d, h:mm a") + : "Not submitted"} +

+
+
+

+ Reviewed +

+

+ {result.gradedAt + ? format(new Date(result.gradedAt), "MMM d, h:mm a") + : "Pending"} +

+
+
+

+ Released +

+

+ {result.releasedAt + ? format(new Date(result.releasedAt), "MMM d, h:mm a") + : "Not released"} +

Answer Summary

- {exam.questions.map((question, index) => { - const answer = answers[question.id] - const isCorrect = - question.type === "mcq" ? answer === question.correctIndex : null - + {result.questions.map((question, index) => { return (

- {question.question} + {question.prompt}

- {question.type === "mcq" ? ( -
- {answer !== undefined ? ( -

- Your answer:{" "} - - {question.options?.[answer as number]} - -

- ) : null} - {isCorrect === false ? ( -

- Correct:{" "} - {question.options?.[question.correctIndex ?? 0]} -

- ) : null} -
- ) : ( - - Pending teacher review +
+ + {formatQuestionStatus(question.status, result.isReleased)} - )} +

+ {formatAnswerPreview(question)} +

+ {result.isReleased && hasCorrectAnswerReview(question) ? ( +

+ Correct answer: {formatCorrectAnswerPreview(question)} +

+ ) : null} +
+
+
+

+ {question.points} pts +

+

+ {question.score === null + ? "Pending" + : `${question.score} / ${question.points}`} +

- - {question.points} pts - ) })}
+ + {!result.isReleased && ( +
+ +

+ Released scores and per-question grading details stay hidden until + the backend marks this attempt as released. +

+
+ )}
) } + +function formatAttemptStatus(status: ReleasedExamResultDto["status"]) { + if (status === "graded") return "Graded" + if (status === "voided") return "Voided" + if (status === "submitted") return "Submitted" + return "In progress" +} + +function formatIntegrityStatus( + status: ReleasedExamResultDto["integrityStatus"], +) { + if (status === "flagged") return "Flagged" + if (status === "voided") return "Voided" + if (status === "reported") return "Reported" + return "Clear" +} + +function integrityBadgeClass(status: ReleasedExamResultDto["integrityStatus"]) { + if (status === "voided") { + return "bg-destructive/10 text-destructive dark:bg-destructive/15" + } + + if (status === "flagged" || status === "reported") { + return "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300" + } + + return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300" +} + +function formatQuestionStatus( + status: ReleasedExamQuestionResultDto["status"], + isReleased: boolean, +) { + if (!isReleased) { + return status === "unanswered" ? "No answer submitted" : "Awaiting release" + } + + if (status === "correct") return "Correct" + if (status === "incorrect") return "Incorrect" + if (status === "reviewed") return "Reviewed" + return "Unanswered" +} + +function formatAnswerPreview(question: ReleasedExamQuestionResultDto) { + if (question.selectedOptionIndex !== null) { + return `Selected option ${String.fromCharCode(65 + question.selectedOptionIndex)}` + } + + if (question.selectedTextAnswer) { + return question.selectedTextAnswer + } + + return "No answer submitted." +} + +function hasCorrectAnswerReview(question: ReleasedExamQuestionResultDto) { + return ( + question.correctOptionIndex !== null || + (question.correctTextAnswer !== null && + question.correctTextAnswer.trim().length > 0) + ) +} + +function formatCorrectAnswerPreview(question: ReleasedExamQuestionResultDto) { + if (question.correctOptionIndex !== null) { + return `Option ${String.fromCharCode(65 + question.correctOptionIndex)}` + } + + if (question.correctTextAnswer) { + return question.correctTextAnswer + } + + return "Not available." +} diff --git a/features/exam/exam-screen.tsx b/features/exam/exam-screen.tsx index 2ab3129..01b3911 100644 --- a/features/exam/exam-screen.tsx +++ b/features/exam/exam-screen.tsx @@ -1,54 +1,291 @@ "use client" -import { AlertCircle, ChevronLeft, ChevronRight, Send } from "lucide-react" +import { useEffect, useRef, useState } from "react" +import { format } from "date-fns" +import { + AlertCircle, + ChevronLeft, + ChevronRight, + Loader2, + Send, +} from "lucide-react" +import { Alert, AlertDescription } from "@/components/ui/alert" import { Button } from "@/components/ui/button" -import type { Class, Exam } from "@/lib/mock-data" +import { Spinner } from "@/components/ui/spinner" +import type { JsonValue, StudentExamPageDto } from "@/lib/exams/types" +import { resolveStudentExamPageState } from "@/lib/education/selectors" import { ExamHeader } from "./exam-header" +import { useExamLock } from "./exam-lock" import { ExamLobby } from "./exam-lobby" -import { ExamResults } from "./exam-results" import { QuestionNavigator } from "./question-navigator" import { QuestionView } from "./question-view" import { useExamSession } from "./use-exam-session" -export function ExamScreen({ cls, exam }: { cls: Class; exam: Exam }) { +type ClassInfo = { + name: string + code: string +} + +export function ExamScreen({ + cls, + page, + isLoading, + isMutating, + errorMessage, + onStartExam, + onSaveAnswer, + onSubmitExam, + onRecordEvent, +}: { + cls: ClassInfo + page: StudentExamPageDto | null + isLoading: boolean + isMutating: boolean + errorMessage: string | null + onStartExam: (examId: string, input: { passcode: string }) => Promise + onSaveAnswer: (input: { + examId: string + attemptId: string + questionId: string + answer: JsonValue | null + }) => Promise + onSubmitExam: (examId: string, attemptId: string) => Promise + onRecordEvent: ( + examId: string, + attemptId: string, + body: { + eventType: string + payload: Record + }, + ) => Promise +}) { + const [passcode, setPasscode] = useState("") + const [actionError, setActionError] = useState(null) + + const pendingSaveRef = useRef | null>(null) + + const state = page ? resolveStudentExamPageState(page) : "none" + const activeExam = page?.activeExam ?? null + const { setExamLock } = useExamLock() + const { - started, - submitted, currentQuestionIndex, answers, timeLeft, - startExam, - submitExam, + isSaving, + saveError, + isSubmitting, + isExamModeBlocked, + examModeError, setCurrentQuestionIndex, setAnswer, - } = useExamSession(exam) + submitExam, + resumeExamMode, + } = useExamSession({ + activeExam: activeExam?.attempt ? activeExam : null, + + onSaveAnswer: async (questionId, answer) => { + if (!activeExam?.attempt) return + + const p = onSaveAnswer({ + examId: activeExam.id, + attemptId: activeExam.attempt.id, + questionId, + answer, + }) + + pendingSaveRef.current = p + await p + }, + + onSubmit: async () => { + if (!activeExam?.attempt) return + + setActionError(null) + + if (pendingSaveRef.current) { + await pendingSaveRef.current + } - if (submitted) { - return + await onSubmitExam(activeExam.id, activeExam.attempt.id) + }, + + onRecordEvent: async (eventType, payload) => { + if (!activeExam?.attempt) return + + await onRecordEvent(activeExam.id, activeExam.attempt.id, { + eventType, + payload: payload ?? {}, + }) + }, + }) + + useEffect(() => { + if (state !== "active") { + setPasscode("") + setActionError(null) + } + }, [state]) + + useEffect(() => { + if (!activeExam?.attempt) { + setExamLock(null) + return + } + + setExamLock({ + classId: activeExam.classId, + examId: activeExam.id, + attemptId: activeExam.attempt.id, + examTitle: activeExam.title, + examRoute: `/classes/${activeExam.classId}/exam`, + }) + }, [activeExam, setExamLock]) + + if (isLoading && !page) { + return ( +
+ + Loading exam... +
+ ) + } + + if (errorMessage && !page) { + return ( +
+ + {errorMessage} + +
+ ) } - if (!started) { - return + if (state === "scheduled" && page?.scheduledExam) { + return ( + {}} + onStart={() => {}} + disabled + actionLabel={ + page.scheduledExam.startAt + ? `Opens ${format(new Date(page.scheduledExam.startAt), "MMM d, h:mm a")}` + : "Scheduled" + } + /> + ) } - const question = exam.questions[currentQuestionIndex] - const answeredCount = Object.keys(answers).length - const progress = Math.round((answeredCount / exam.questions.length) * 100) + if (!activeExam) { + return ( +
No exam available
+ ) + } + + if (!activeExam.attempt) { + return ( +
+ {(errorMessage || actionError) && ( +
+ + {actionError ?? errorMessage} + +
+ )} + + void onStartExam(activeExam.id, { passcode })} + disabled={ + isMutating || + !activeExam.canStartAttempt || + (activeExam.requiresPasscode && passcode.trim().length < 4) + } + actionLabel={ + isMutating + ? "Starting..." + : activeExam.canStartAttempt + ? "Start Exam" + : "Start unavailable" + } + /> +
+ ) + } + + const question = activeExam.questions[currentQuestionIndex] ?? null + + const answeredCount = activeExam.questions.filter((q) => { + const ans = answers[q.id] + return ans !== undefined && ans !== null && ans !== "" + }).length + + const progress = + activeExam.questions.length > 0 + ? Math.round((answeredCount / activeExam.questions.length) * 100) + : 0 + + if (!question) { + return ( +
+ + + This exam does not have any published questions yet. + + +
+ ) + } return (
+ {(errorMessage || actionError || saveError || examModeError) && ( +
+ + + {actionError ?? examModeError ?? saveError ?? errorMessage} + + +
+ )} + void submitExam()} />
setAnswer(question.id, value)} /> -
+ +
- {currentQuestionIndex < exam.questions.length - 1 ? ( + + {currentQuestionIndex < activeExam.questions.length - 1 ? ( ) : ( - )}
-
- ) -} -export function NoExamState() { - return ( -
- -

No exam available

-

- There is no active exam for this class. -

+ {activeExam.examModeEnabled && isExamModeBlocked && ( +
+ +
+ )}
) } diff --git a/features/exam/manager-detail-state.test.ts b/features/exam/manager-detail-state.test.ts new file mode 100644 index 0000000..7902f3f --- /dev/null +++ b/features/exam/manager-detail-state.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, test } from "bun:test" +import { + buildGradeInputsForAttempt, + getEndedExamApprovalStatus, + getAttemptGradeIndicator, + getAttemptMonitorStatus, + getExamMonitorSummary, + isAttemptSuspicious, + resolveSelectedAttemptId, +} from "@/features/exam/manager-detail-state" + +const attempt = { + id: "attempt-1", + studentUserId: "student-1", + studentDisplayName: "Student One", + studentEmail: "student@example.com", + status: "submitted" as const, + startedAt: "2026-05-04T08:00:00Z", + submittedAt: "2026-05-04T08:20:00Z", + totalScore: null, + attemptNumber: 1, + needsManualReview: true, + integrityStatus: "clear" as const, + resultsReleasedAt: null, + availableRetakeCount: 0, + answers: [ + { + id: "answer-1", + questionId: "question-1", + answer: "draft", + autoScore: 2, + teacherScore: null, + }, + { + id: "answer-2", + questionId: "question-2", + answer: 1, + autoScore: 5, + teacherScore: 4, + }, + ], + integrityEvents: [], +} + +describe("resolveSelectedAttemptId", () => { + test("keeps the detail panel closed until a student card is selected", () => { + expect( + resolveSelectedAttemptId({ + attempts: [attempt], + currentSelectedAttemptId: null, + }), + ).toEqual(null) + }) + + test("keeps the current selected attempt during a background refresh", () => { + expect( + resolveSelectedAttemptId({ + attempts: [attempt], + currentSelectedAttemptId: "attempt-1", + }), + ).toEqual("attempt-1") + }) + + test("falls back to the first attempt when the previous selection disappears", () => { + expect( + resolveSelectedAttemptId({ + attempts: [attempt], + currentSelectedAttemptId: "missing-attempt", + }), + ).toEqual("attempt-1") + }) +}) + +describe("buildGradeInputsForAttempt", () => { + test("keeps existing teacher grades stable across detail refreshes", () => { + expect(buildGradeInputsForAttempt(attempt)).toEqual({ + "question-1": "", + "question-2": "4", + }) + }) +}) + +describe("monitor card helpers", () => { + test("marks attempts with integrity events as suspicious", () => { + expect( + isAttemptSuspicious({ + integrityStatus: "clear", + integrityEvents: [ + { + key: "event-1", + eventType: "fullscreen_exit", + createdAt: "2026-05-04T08:05:00Z", + payload: {}, + }, + ], + }), + ).toEqual(true) + }) + + test("returns normal status for clean attempts", () => { + expect( + getAttemptMonitorStatus({ + integrityStatus: "clear", + integrityEvents: [], + }), + ).toEqual("Normal") + }) + + test("prioritizes released indicator over grading states", () => { + expect( + getAttemptGradeIndicator({ + resultsReleasedAt: "2026-05-04T08:25:00Z", + needsManualReview: false, + status: "graded", + totalScore: 19, + }), + ).toEqual("Released") + }) + + test("shows needs grading for manual-review attempts", () => { + expect( + getAttemptGradeIndicator({ + resultsReleasedAt: null, + needsManualReview: true, + status: "submitted", + totalScore: null, + }), + ).toEqual("Needs grading") + }) + + test("shows voided when the attempt was voided", () => { + expect( + getAttemptGradeIndicator({ + resultsReleasedAt: null, + needsManualReview: false, + status: "voided", + totalScore: null, + }), + ).toEqual("Voided") + }) + + test("summarizes suspicious, graded, and entered students without double-counting retakes", () => { + expect( + getExamMonitorSummary([ + attempt, + { + ...attempt, + status: "graded", + resultsReleasedAt: "2026-05-04T08:30:00Z", + integrityStatus: "flagged", + integrityEvents: [ + { + key: "event-2", + eventType: "visibility_hidden", + createdAt: "2026-05-04T08:10:00Z", + payload: {}, + }, + ], + }, + { + ...attempt, + studentUserId: "student-2", + integrityStatus: "clear", + integrityEvents: [], + resultsReleasedAt: null, + }, + ] as any), + ).toEqual({ + suspiciousStudents: 1, + gradedStudents: 1, + enteredStudents: 2, + }) + }) + + test("shows grades confirmed when all entered students have approved results", () => { + expect( + getEndedExamApprovalStatus({ + status: "ended", + enteredStudentCount: 2, + releasedStudentCount: 2, + }), + ).toEqual({ + label: "Grades confirmed", + tone: "confirmed", + }) + }) + + test("shows waiting for approval when an ended exam still has pending grades", () => { + expect( + getEndedExamApprovalStatus({ + status: "ended", + enteredStudentCount: 3, + releasedStudentCount: 1, + }), + ).toEqual({ + label: "Waiting for approval", + tone: "pending", + }) + }) +}) diff --git a/features/exam/manager-detail-state.ts b/features/exam/manager-detail-state.ts new file mode 100644 index 0000000..5d4a676 --- /dev/null +++ b/features/exam/manager-detail-state.ts @@ -0,0 +1,137 @@ +import type { + ManagerAttemptSummaryDto, + ManagerExamSummaryDto, +} from "@/lib/exams/types" + +export function getExamMonitorSummary( + attempts: Pick< + ManagerAttemptSummaryDto, + | "studentUserId" + | "status" + | "integrityStatus" + | "integrityEvents" + | "resultsReleasedAt" + >[], +) { + const enteredStudents = new Set() + const suspiciousStudents = new Set() + const gradedStudents = new Set() + + for (const attempt of attempts) { + enteredStudents.add(attempt.studentUserId) + + if (attempt.status === "voided" || isAttemptSuspicious(attempt)) { + suspiciousStudents.add(attempt.studentUserId) + } + + if (attempt.resultsReleasedAt) { + gradedStudents.add(attempt.studentUserId) + } + } + + return { + suspiciousStudents: suspiciousStudents.size, + gradedStudents: gradedStudents.size, + enteredStudents: enteredStudents.size, + } +} + +export function getEndedExamApprovalStatus( + exam: Pick< + ManagerExamSummaryDto, + "status" | "enteredStudentCount" | "releasedStudentCount" + >, +) { + if (exam.status !== "ended") { + return null + } + + const isConfirmed = + exam.enteredStudentCount > 0 && + exam.releasedStudentCount >= exam.enteredStudentCount + + return isConfirmed + ? { + label: "Grades confirmed", + tone: "confirmed" as const, + } + : { + label: "Waiting for approval", + tone: "pending" as const, + } +} + +export function resolveSelectedAttemptId(input: { + attempts: ManagerAttemptSummaryDto[] + currentSelectedAttemptId: string | null +}) { + if (input.currentSelectedAttemptId === null) { + return null + } + + const existingSelection = input.currentSelectedAttemptId + ? input.attempts.find( + (attempt) => attempt.id === input.currentSelectedAttemptId, + ) + : null + + return existingSelection?.id ?? input.attempts[0]?.id ?? null +} + +export function buildGradeInputsForAttempt( + attempt: ManagerAttemptSummaryDto | null | undefined, +) { + if (!attempt) return {} + + return Object.fromEntries( + attempt.answers.map((answer) => [ + answer.questionId, + answer.teacherScore === null ? "" : String(answer.teacherScore), + ]), + ) +} + +export function isAttemptSuspicious( + attempt: Pick< + ManagerAttemptSummaryDto, + "integrityStatus" | "integrityEvents" + >, +) { + return ( + attempt.integrityStatus !== "clear" || attempt.integrityEvents.length > 0 + ) +} + +export function getAttemptMonitorStatus( + attempt: Pick< + ManagerAttemptSummaryDto, + "integrityStatus" | "integrityEvents" + >, +) { + return isAttemptSuspicious(attempt) ? "Suspicious" : "Normal" +} + +export function getAttemptGradeIndicator( + attempt: Pick< + ManagerAttemptSummaryDto, + "resultsReleasedAt" | "needsManualReview" | "status" | "totalScore" + >, +) { + if (attempt.status === "voided") { + return "Voided" + } + + if (attempt.resultsReleasedAt) { + return "Released" + } + + if (attempt.needsManualReview) { + return "Needs grading" + } + + if (attempt.totalScore === null) { + return "Pending score" + } + + return "Awaiting approval" +} diff --git a/features/exam/manager-exam-screen.tsx b/features/exam/manager-exam-screen.tsx new file mode 100644 index 0000000..02c7a09 --- /dev/null +++ b/features/exam/manager-exam-screen.tsx @@ -0,0 +1,1610 @@ +"use client" + +import { format } from "date-fns" +import { + CheckCircle2, + ClipboardList, + Eye, + Loader2, + PlusCircle, + RotateCcw, + ShieldAlert, + Trash2, + Users, +} from "lucide-react" +import { useState, type FormEvent } from "react" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Spinner } from "@/components/ui/spinner" +import { Textarea } from "@/components/ui/textarea" +import { StatCard } from "@/components/shared/stat-card" +import type { + GradeAttemptInput, + ManagerExamDetailDto, + ManagerExamSummaryDto, + UpsertExamInput, + UpsertExamQuestionInput, +} from "@/lib/exams/types" +import { canTeacherGradeQuestion } from "@/lib/exams/grading" +import { formatIntegrityEvent } from "@/lib/exams/integrity" +import type { Class } from "@/lib/mock-data" +import { cn } from "@/lib/utils" +import { + buildGradeInputsForAttempt, + getEndedExamApprovalStatus, + getAttemptGradeIndicator, + getAttemptMonitorStatus, + getExamMonitorSummary, + isAttemptSuspicious, + resolveSelectedAttemptId, +} from "./manager-detail-state" +import type { UseClassExamResult } from "./use-class-exam" + +type QuestionEditorState = { + type: UpsertExamQuestionInput["type"] + prompt: string + points: string + optionsText: string + correctAnswerText: string +} + +type ExamFormState = { + title: string + durationMinutes: string + startAt: string + passcode: string + questions: QuestionEditorState[] +} + +const EMPTY_QUESTION: QuestionEditorState = { + type: "mcq", + prompt: "", + points: "10", + optionsText: "Option A\nOption B", + correctAnswerText: "1", +} + +const EMPTY_FORM: ExamFormState = { + title: "", + durationMinutes: "60", + startAt: "", + passcode: "", + questions: [{ ...EMPTY_QUESTION }], +} + +export function ManagerExamScreen({ + cls, + examApi, +}: { + cls: Pick + examApi: UseClassExamResult +}) { + const { + data, + isLoading, + isRefreshing, + isMutating, + errorMessage, + createExam, + updateExam, + publishExam, + deleteExam, + grantRetake, + getExamDetail, + gradeAttempt, + updateIntegrity, + } = examApi + const [isCreateOpen, setIsCreateOpen] = useState(false) + const [form, setForm] = useState(EMPTY_FORM) + const [formError, setFormError] = useState(null) + const [editingExam, setEditingExam] = useState( + null, + ) + const [editingPasscodeProtected, setEditingPasscodeProtected] = + useState(false) + const [detailExamId, setDetailExamId] = useState(null) + const [detail, setDetail] = useState(null) + const [detailError, setDetailError] = useState(null) + const [isDetailLoading, setIsDetailLoading] = useState(false) + const [isDetailRefreshing, setIsDetailRefreshing] = useState(false) + const [successMessage, setSuccessMessage] = useState(null) + const [selectedAttemptId, setSelectedAttemptId] = useState( + null, + ) + const [gradeInputs, setGradeInputs] = useState>({}) + + const exams = data?.canManage ? data.manager.exams : [] + const selectedAttempt = detail?.attempts.find( + (attempt) => attempt.id === selectedAttemptId, + ) + const isLiveMonitor = detail?.exam.status === "live" + const monitorSummary = detail ? getExamMonitorSummary(detail.attempts) : null + const suspiciousAttemptCount = + detail?.attempts.filter(isAttemptSuspicious).length ?? 0 + const detailApprovalStatus = detail + ? getEndedExamApprovalStatus(detail.exam) + : null + const selectedAttemptMonitorStatus = selectedAttempt + ? getAttemptMonitorStatus(selectedAttempt) + : null + const selectedAttemptGradeIndicator = selectedAttempt + ? getAttemptGradeIndicator(selectedAttempt) + : null + const canGradeSelectedAttempt = + selectedAttempt !== undefined && + selectedAttempt !== null && + selectedAttempt.status !== "in_progress" && + selectedAttempt.status !== "voided" && + !selectedAttempt.resultsReleasedAt + const canGrantSelectedRetake = + selectedAttempt !== undefined && + selectedAttempt !== null && + selectedAttempt.status !== "in_progress" && + selectedAttempt.availableRetakeCount === 0 + + async function openDetail(examId: string) { + setDetailExamId(examId) + setDetail(null) + setDetailError(null) + setSelectedAttemptId(null) + setIsDetailLoading(true) + + try { + const nextDetail = await getExamDetail(examId) + applyDetailState(nextDetail, null) + } catch (error) { + setDetailError( + error instanceof Error ? error.message : "Could not load exam detail.", + ) + } finally { + setIsDetailLoading(false) + } + } + + async function refreshDetail(examId: string) { + setIsDetailRefreshing(true) + setDetailError(null) + + try { + const nextDetail = await getExamDetail(examId) + applyDetailState(nextDetail, selectedAttemptId) + } catch (error) { + setDetailError( + error instanceof Error + ? error.message + : "Could not refresh exam detail.", + ) + } finally { + setIsDetailRefreshing(false) + } + } + + function applyDetailState( + nextDetail: ManagerExamDetailDto, + nextSelectedAttemptId: string | null, + ) { + setDetail(nextDetail) + const resolvedAttemptId = resolveSelectedAttemptId({ + attempts: nextDetail.attempts, + currentSelectedAttemptId: nextSelectedAttemptId, + }) + const resolvedAttempt = + nextDetail.attempts.find((attempt) => attempt.id === resolvedAttemptId) ?? + null + setSelectedAttemptId(resolvedAttemptId) + setGradeInputs(buildGradeInputsForAttempt(resolvedAttempt)) + } + + function resetForm() { + setForm(EMPTY_FORM) + setFormError(null) + setEditingExam(null) + setEditingPasscodeProtected(false) + } + + function openCreate() { + resetForm() + setIsCreateOpen(true) + } + + async function openEdit(examId: string) { + try { + setFormError(null) + const nextDetail = await getExamDetail(examId) + hydrateFormFromDetail(nextDetail) + } catch (error) { + setFormError( + error instanceof Error ? error.message : "Could not load exam detail.", + ) + } + } + + function hydrateFormFromDetail(nextDetail: ManagerExamDetailDto) { + setEditingExam(nextDetail.exam) + setEditingPasscodeProtected(nextDetail.exam.passcodeProtected) + setForm({ + title: nextDetail.exam.title, + durationMinutes: String(nextDetail.exam.durationMinutes), + startAt: toDatetimeLocalValue(nextDetail.exam.startAt), + passcode: "", + questions: nextDetail.questions.map((question) => ({ + type: toSupportedQuestionType(question.type), + prompt: question.prompt, + points: String(question.points), + optionsText: question.options.join("\n"), + correctAnswerText: + typeof question.correctAnswer === "number" + ? String(question.correctAnswer + 1) + : typeof question.correctAnswer === "string" + ? question.correctAnswer + : "", + })), + }) + setIsCreateOpen(true) + } + + async function submitForm(event: FormEvent) { + event.preventDefault() + + try { + setFormError(null) + setSuccessMessage(null) + const payload = toExamPayload(form, { + editingPasscodeProtected, + }) + if (editingExam) { + await updateExam(editingExam.id, payload) + } else { + await createExam(payload) + } + setIsCreateOpen(false) + resetForm() + } catch (error) { + setFormError( + error instanceof Error ? error.message : "Could not save exam.", + ) + } + } + + async function publishSelectedExam(examId: string) { + try { + setDetailError(null) + await publishExam(examId) + setSuccessMessage("Exam published successfully.") + if (detailExamId === examId) { + await refreshDetail(examId) + } + } catch (error) { + setSuccessMessage(null) + setDetailError( + error instanceof Error ? error.message : "Could not publish exam.", + ) + } + } + + async function deleteSelectedExam(examId: string) { + if ( + typeof window !== "undefined" && + !window.confirm( + "Delete this exam and all of its attempts? This action cannot be undone.", + ) + ) { + return + } + + try { + setSuccessMessage(null) + await deleteExam(examId) + if (detailExamId === examId) { + setDetailExamId(null) + setDetail(null) + setSelectedAttemptId(null) + } + } catch (error) { + setDetailError( + error instanceof Error ? error.message : "Could not delete exam.", + ) + } + } + + async function submitGrades() { + if (!detail || !selectedAttempt) return + + const answers: GradeAttemptInput["answers"] = detail.questions + .filter((question) => + canTeacherGradeQuestion({ + questionType: question.type, + correctAnswer: question.correctAnswer, + }), + ) + .map((question) => ({ + questionId: question.id, + teacherScore: + gradeInputs[question.id] === "" + ? null + : Number.parseFloat(gradeInputs[question.id] ?? ""), + })) + + try { + setSuccessMessage(null) + await gradeAttempt(detail.exam.id, selectedAttempt.id, { answers }) + await refreshDetail(detail.exam.id) + } catch (error) { + setDetailError( + error instanceof Error ? error.message : "Could not save grade.", + ) + } + } + + async function updateSelectedIntegrity(action: "flag" | "void" | "clear") { + if (!detail || !selectedAttempt) return + + try { + setSuccessMessage(null) + await updateIntegrity(detail.exam.id, selectedAttempt.id, { + action, + }) + await refreshDetail(detail.exam.id) + } catch (error) { + setDetailError( + error instanceof Error + ? error.message + : "Could not update integrity state.", + ) + } + } + + async function grantSelectedRetake() { + if (!detail || !selectedAttempt) return + + try { + setSuccessMessage(null) + await grantRetake(detail.exam.id, selectedAttempt.id) + await refreshDetail(detail.exam.id) + } catch (error) { + setDetailError( + error instanceof Error ? error.message : "Could not grant retake.", + ) + } + } + + if (isLoading && !data) { + return ( +
+ + Loading exams... +
+ ) + } + + return ( +
+
+
+

{cls.name}

+

+ {cls.code} · {exams.length} exams + {isRefreshing ? " · Refreshing..." : ""} +

+
+ +
+ + {(errorMessage || formError) && ( + + {errorMessage ?? formError} + + )} + + {successMessage && ( + + + {successMessage} + + )} + + {exams.length === 0 ? ( +
+ +

No exams yet

+
+ ) : ( +
+ {exams.map((exam) => { + const endedApprovalStatus = getEndedExamApprovalStatus(exam) + + return ( + + +
+ +
+
+
+

+ {exam.title} +

+ + {formatExamStatus(exam.status)} + + {!exam.publishedAt && ( + Draft + )} + {endedApprovalStatus ? ( + + {endedApprovalStatus.label} + + ) : null} +
+
+ {exam.durationMinutes} min + {exam.totalPoints} pts + + {exam.startAt + ? format(new Date(exam.startAt), "MMM d, h:mm a") + : "No start time"} + + + {exam.attemptCounts.inProgress} in progress ·{" "} + {exam.attemptCounts.submitted} submitted ·{" "} + {exam.attemptCounts.graded} graded ·{" "} + {exam.attemptCounts.released} released + + {exam.enteredStudentCount} student entries +
+
+
+ + {canEditExam(exam) && ( + + )} + {!exam.publishedAt && ( + + )} + +
+
+
+ ) + })} +
+ )} + + { + setIsCreateOpen(open) + if (!open && !isMutating) resetForm() + }} + > + +
+ + + {editingExam ? "Edit exam" : "Create exam"} + + + Build and publish a secure class exam. + + + + {formError && ( + + {formError} + + )} + +
+ + setForm((current) => ({ ...current, title: value })) + } + required + /> + + setForm((current) => ({ ...current, durationMinutes: value })) + } + min="1" + required + /> +
+ + setForm((current) => ({ ...current, startAt: value })) + } + /> +

+ The exam end time is calculated automatically from the start + time and duration. +

+
+ + setForm((current) => ({ ...current, passcode: value })) + } + minLength={4} + placeholder={ + editingPasscodeProtected + ? "Passcode already set (leave empty to keep current)" + : "At least 4 characters" + } + required={!editingPasscodeProtected} + /> + {editingPasscodeProtected && ( +

+ Leave the passcode blank to keep the current exam passcode, or + enter a new one with at least 4 characters to replace it. +

+ )} + {!editingPasscodeProtected && ( +

+ Every exam requires a passcode. Use at least 4 characters. +

+ )} +
+ +
+
+

Questions

+
+ + +
+
+ + {form.questions.map((question, index) => ( + + + + + Question {index + 1} ·{" "} + {formatQuestionType(question.type)} + + + + + +
+
+ +