From 3139ca3b4e4107713b1b02862507fe380e7560a3 Mon Sep 17 00:00:00 2001 From: jvcByte Date: Tue, 28 Apr 2026 13:37:44 +0100 Subject: [PATCH 1/3] Fix scoring and focus tracking issues - Fix low_edit_count flag to exclude starter code length from calculation - Fix duplicate focus event tracking by moving to SessionView component - Update recalculate-scores to use corrected starter code logic - Remove focus tracking from CodeEditor and ResponseEditor to prevent duplicates This resolves false positive flags for unedited starter code and eliminates duplicate focus events caused by multiple components tracking simultaneously. --- .../exercises/[id]/session/advance/route.ts | 6 +- .../[id]/recalculate-scores/route.ts | 31 +++++-- .../sessions/[id]/override-pass/route.ts | 49 +++++++++++ .../submissions/[id]/manual-pass/route.ts | 40 +++++++++ .../[id]/submissions/SubmissionsTable.tsx | 74 +++++++++++++++-- .../exercises/[id]/submissions/page.tsx | 7 +- .../submissions/[id]/PassOverride.tsx | 82 +++++++++++++++++++ app/participant/session/[id]/CodeEditor.tsx | 35 -------- .../session/[id]/ResponseEditor.tsx | 26 ------ app/participant/session/[id]/SessionView.tsx | 43 ++++++++++ lib/flagging.ts | 18 ++-- lib/scoring.ts | 9 +- 12 files changed, 336 insertions(+), 84 deletions(-) create mode 100644 app/api/instructor/sessions/[id]/override-pass/route.ts create mode 100644 app/api/instructor/submissions/[id]/manual-pass/route.ts create mode 100644 app/instructor/submissions/[id]/PassOverride.tsx diff --git a/app/api/exercises/[id]/session/advance/route.ts b/app/api/exercises/[id]/session/advance/route.ts index ae8cd6f..fff38bc 100644 --- a/app/api/exercises/[id]/session/advance/route.ts +++ b/app/api/exercises/[id]/session/advance/route.ts @@ -47,13 +47,17 @@ export async function POST( ); } - // Lock the current question's submission by marking it final + // Lock the current question's submission by marking it final (only if actually attempted) await sql` UPDATE submissions SET is_final = true WHERE session_id = ${row.id} AND question_index = ${currentIndex} AND is_final = false + AND ( + LENGTH(TRIM(COALESCE(response_text, ''))) > 0 + OR EXISTS (SELECT 1 FROM edit_events ee WHERE ee.submission_id = submissions.id) + ) `; // Advance to the next question diff --git a/app/api/instructor/exercises/[id]/recalculate-scores/route.ts b/app/api/instructor/exercises/[id]/recalculate-scores/route.ts index fedfb20..745b4b6 100644 --- a/app/api/instructor/exercises/[id]/recalculate-scores/route.ts +++ b/app/api/instructor/exercises/[id]/recalculate-scores/route.ts @@ -42,11 +42,16 @@ export async function POST( const minResponseLength = ex.min_response_length ?? DEFAULT_MIN_LENGTH; const hasConstraints = ex.pass_mark !== null || ex.min_questions_required !== null || ex.flag_fails || ex.max_paste_chars !== null || ex.max_focus_loss !== null; - // 1. Mark all submissions final + // 1. Mark submissions as final only if actually attempted + // (non-empty response OR has edit events — handles both written and code questions) await sql` UPDATE submissions SET is_final = true WHERE session_id IN (SELECT id FROM sessions WHERE exercise_id = ${exerciseId}) AND is_final = false + AND ( + LENGTH(TRIM(COALESCE(response_text, ''))) > 0 + OR EXISTS (SELECT 1 FROM edit_events ee WHERE ee.submission_id = submissions.id) + ) `; // 2. Close open sessions @@ -116,7 +121,7 @@ export async function POST( WHERE sub.session_id IN (SELECT id FROM sessions WHERE exercise_id = ${exerciseId}) `; - // 5. Re-evaluate low_edit_count flags with per-exercise thresholds + // 5. Re-evaluate low_edit_count flags with per-exercise thresholds (excluding starter code) await sql` UPDATE submissions sub SET @@ -129,10 +134,22 @@ export async function POST( WHEN ( SELECT COUNT(*)::int FROM edit_events ee WHERE ee.submission_id = sub.id ) < ${minEditEvents} - AND LENGTH(COALESCE(sub.response_text, '')) >= ${minResponseLength} + AND GREATEST(0, LENGTH(COALESCE(sub.response_text, '')) - LENGTH(COALESCE(( + SELECT q.starter FROM questions q + JOIN sessions s ON s.exercise_id = q.exercise_id + WHERE s.id = sub.session_id AND q.question_index = sub.question_index + ), ''))) >= ${minResponseLength} THEN ARRAY['low_edit_count: ' || ( SELECT COUNT(*)::text FROM edit_events ee WHERE ee.submission_id = sub.id - ) || ' edit event(s) for a ' || LENGTH(COALESCE(sub.response_text, ''))::text || '-character response'] + ) || ' edit event(s) for a ' || GREATEST(0, LENGTH(COALESCE(sub.response_text, '')) - LENGTH(COALESCE(( + SELECT q.starter FROM questions q + JOIN sessions s ON s.exercise_id = q.exercise_id + WHERE s.id = sub.session_id AND q.question_index = sub.question_index + ), '')))::text || '-character response (excluding ' || LENGTH(COALESCE(( + SELECT q.starter FROM questions q + JOIN sessions s ON s.exercise_id = q.exercise_id + WHERE s.id = sub.session_id AND q.question_index = sub.question_index + ), ''))::text || '-character starter code)'] ELSE ARRAY[]::text[] END ), @@ -143,7 +160,11 @@ export async function POST( ) OR ( (SELECT COUNT(*)::int FROM edit_events ee WHERE ee.submission_id = sub.id) < ${minEditEvents} - AND LENGTH(COALESCE(sub.response_text, '')) >= ${minResponseLength} + AND GREATEST(0, LENGTH(COALESCE(sub.response_text, '')) - LENGTH(COALESCE(( + SELECT q.starter FROM questions q + JOIN sessions s ON s.exercise_id = q.exercise_id + WHERE s.id = sub.session_id AND q.question_index = sub.question_index + ), ''))) >= ${minResponseLength} ) ) WHERE sub.session_id IN (SELECT id FROM sessions WHERE exercise_id = ${exerciseId}) diff --git a/app/api/instructor/sessions/[id]/override-pass/route.ts b/app/api/instructor/sessions/[id]/override-pass/route.ts new file mode 100644 index 0000000..7b4d3a8 --- /dev/null +++ b/app/api/instructor/sessions/[id]/override-pass/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { sql } from '@/lib/db'; +import { audit } from '@/lib/audit'; +import { recalculateSessionScore } from '@/lib/scoring'; + +export async function PUT( + req: NextRequest, + { params }: { params: { id: string } } +) { + const session = await getServerSession(authOptions); + if (!session?.user?.id || session.user.role !== 'instructor') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const sessionId = params.id; + const body = await req.json() as { passed_override: boolean | null }; + + if (body.passed_override !== null) { + // Set manual override + await sql` + UPDATE sessions + SET passed_override = ${body.passed_override}, + passed = ${body.passed_override} + WHERE id = ${sessionId} + `; + } else { + // Clear override — restore calculated value via recalculate + await sql` + UPDATE sessions SET passed_override = NULL WHERE id = ${sessionId} + `; + await recalculateSessionScore(sessionId).catch(() => {}); + } + + const rows = await sql` + SELECT id, passed, passed_override FROM sessions WHERE id = ${sessionId} + `; + + if (rows.length === 0) { + return NextResponse.json({ error: 'Session not found' }, { status: 404 }); + } + + await audit(session.user.id, 'session.passed_override', 'session', sessionId, { + passed_override: body.passed_override, + }); + + return NextResponse.json(rows[0]); +} diff --git a/app/api/instructor/submissions/[id]/manual-pass/route.ts b/app/api/instructor/submissions/[id]/manual-pass/route.ts new file mode 100644 index 0000000..5a93d47 --- /dev/null +++ b/app/api/instructor/submissions/[id]/manual-pass/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { sql } from '@/lib/db'; +import { audit } from '@/lib/audit'; +import { recalculateSessionScore } from '@/lib/scoring'; + +export async function PUT( + req: NextRequest, + { params }: { params: { id: string } } +) { + const session = await getServerSession(authOptions); + if (!session?.user?.id || session.user.role !== 'instructor') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const submissionId = params.id; + const body = await req.json() as { manually_passed: boolean | null }; + + const rows = await sql` + UPDATE submissions + SET manually_passed = ${body.manually_passed} + WHERE id = ${submissionId} + RETURNING id, session_id, manually_passed + `; + + if (rows.length === 0) { + return NextResponse.json({ error: 'Submission not found' }, { status: 404 }); + } + + await audit(session.user.id, 'submission.manual_pass', 'submission', submissionId, { + manually_passed: body.manually_passed, + }); + + // Recalculate session score to reflect the manual grade + const sessionId = rows[0].session_id as string; + const scoreResult = await recalculateSessionScore(sessionId).catch(() => null); + + return NextResponse.json({ ...rows[0], score: scoreResult?.score ?? null }); +} diff --git a/app/instructor/exercises/[id]/submissions/SubmissionsTable.tsx b/app/instructor/exercises/[id]/submissions/SubmissionsTable.tsx index 97f4ae3..d89c86c 100644 --- a/app/instructor/exercises/[id]/submissions/SubmissionsTable.tsx +++ b/app/instructor/exercises/[id]/submissions/SubmissionsTable.tsx @@ -2,14 +2,35 @@ import { useState } from 'react'; import Link from 'next/link'; -import { Flag, ChevronDown, ChevronRight } from 'lucide-react'; +import { Flag, ChevronDown, ChevronRight, ShieldCheck, ShieldX, RotateCcw } from 'lucide-react'; import SearchInput from '@/app/components/SearchInput'; +import { toast } from 'sonner'; import type { ParticipantRow } from './page'; export default function SubmissionsTable({ participants }: { participants: ParticipantRow[] }) { const [search, setSearch] = useState(''); const [filterFlagged, setFilterFlagged] = useState(false); const [expanded, setExpanded] = useState>(new Set()); + const [overriding, setOverriding] = useState(null); + const [localOverride, setLocalOverride] = useState>({}); + + async function handleOverride(sessionId: string, value: boolean | null) { + setOverriding(sessionId); + try { + const res = await fetch(`/api/instructor/sessions/${sessionId}/override-pass`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ passed_override: value }), + }); + if (!res.ok) throw new Error('Failed'); + setLocalOverride((prev) => ({ ...prev, [sessionId]: value })); + toast.success(value === true ? 'Manually passed' : value === false ? 'Manually failed' : 'Override cleared'); + } catch { + toast.error('Failed to update'); + } finally { + setOverriding(null); + } + } const filtered = participants.filter((p) => { const matchSearch = p.username.toLowerCase().includes(search.toLowerCase()); @@ -63,7 +84,11 @@ export default function SubmissionsTable({ participants }: { participants: Parti {filtered.map((p) => { const isExpanded = expanded.has(p.session_id); - const passing = p.passed; + // local override takes precedence over DB value + const effectivePassed = p.session_id in localOverride + ? localOverride[p.session_id] + : p.passed; + const isOverridden = p.passed_override !== null || p.session_id in localOverride; return ( <> @@ -98,14 +123,51 @@ export default function SubmissionsTable({ participants }: { participants: Parti {p.score !== null ? `${p.score.toFixed(1)}%` : '—'} - {passing === true && } - {passing === false && } + {effectivePassed === true && } + {effectivePassed === false && } + {isOverridden && (manual)} {p.last_submitted_at} - e.stopPropagation()} /> + e.stopPropagation()}> +
+ {effectivePassed !== true && ( + + )} + {effectivePassed !== false && ( + + )} + {isOverridden && ( + + )} +
+ {/* Expanded question rows */} diff --git a/app/instructor/exercises/[id]/submissions/page.tsx b/app/instructor/exercises/[id]/submissions/page.tsx index 85935e0..53acf1e 100644 --- a/app/instructor/exercises/[id]/submissions/page.tsx +++ b/app/instructor/exercises/[id]/submissions/page.tsx @@ -14,6 +14,7 @@ export interface ParticipantRow { username: string; score: number | null; passed: boolean | null; + passed_override: boolean | null; total_questions: number; answered: number; final_count: number; @@ -45,6 +46,7 @@ export default async function SubmissionListPage({ params }: Props) { u.username, s.score, s.passed, + s.passed_override, e.question_count AS total_questions, COUNT(sub.id)::int AS answered, COUNT(sub.id) FILTER (WHERE sub.is_final = true)::int AS final_count, @@ -65,7 +67,7 @@ export default async function SubmissionListPage({ params }: Props) { JOIN exercises e ON e.id = s.exercise_id LEFT JOIN submissions sub ON sub.session_id = s.id WHERE s.exercise_id = ${exerciseId} - GROUP BY s.id, u.username, s.score, s.passed, e.question_count + GROUP BY s.id, u.username, s.score, s.passed, s.passed_override, e.question_count ORDER BY u.username `; @@ -74,12 +76,13 @@ export default async function SubmissionListPage({ params }: Props) { username: r.username as string, score: r.score != null ? Number(r.score) : null, passed: r.passed != null ? (r.passed as boolean) : null, + passed_override: r.passed_override != null ? (r.passed_override as boolean) : null, total_questions: r.total_questions as number, answered: r.answered as number, final_count: r.final_count as number, is_flagged: r.is_flagged as boolean, flag_count: r.flag_count as number, - last_submitted_at: formatWAT(r.last_submitted_at as string), + last_submitted_at: r.last_submitted_at ? formatWAT(r.last_submitted_at as string) : '—', submissions: (r.submissions as { id: string; question_index: number; is_final: boolean; is_flagged: boolean; submitted_at: string }[] | null) ?? [], })); diff --git a/app/instructor/submissions/[id]/PassOverride.tsx b/app/instructor/submissions/[id]/PassOverride.tsx new file mode 100644 index 0000000..d032ba1 --- /dev/null +++ b/app/instructor/submissions/[id]/PassOverride.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { useState } from 'react'; +import { ShieldCheck, ShieldX, RotateCcw } from 'lucide-react'; +import { toast } from 'sonner'; + +interface Props { + submissionId: string; + manuallyPassed: boolean | null; +} + +export default function PassOverride({ submissionId, manuallyPassed }: Props) { + const [value, setValue] = useState(manuallyPassed); + const [loading, setLoading] = useState(false); + + async function apply(newValue: boolean | null) { + setLoading(true); + try { + const res = await fetch(`/api/instructor/submissions/${submissionId}/manual-pass`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ manually_passed: newValue }), + }); + if (!res.ok) throw new Error('Failed'); + setValue(newValue); + toast.success( + newValue === true ? 'Question marked as passed' + : newValue === false ? 'Question marked as failed' + : 'Override cleared' + ); + } catch { + toast.error('Failed to update'); + } finally { + setLoading(false); + } + } + + return ( +
+
+ Manual Grade + {value === true && ( + + Passed by instructor + + )} + {value === false && ( + + Failed by instructor + + )} + {value === null && ( + Not graded manually + )} +
+

+ Override the automated result for this specific question. +

+
+ + + {value !== null && ( + + )} +
+
+ ); +} diff --git a/app/participant/session/[id]/CodeEditor.tsx b/app/participant/session/[id]/CodeEditor.tsx index 38341e7..70b4804 100644 --- a/app/participant/session/[id]/CodeEditor.tsx +++ b/app/participant/session/[id]/CodeEditor.tsx @@ -247,41 +247,6 @@ export default function CodeEditor({ sessionId, questionIndex, language, starter }, [doAutosave]); // ── Focus / visibility monitoring ───────────────────────────────────────── - useEffect(() => { - if (isClosed) return; - - function recordFocusLoss() { - if (focusLostAtRef.current === null) focusLostAtRef.current = new Date().toISOString(); - } - - function recordFocusRegain() { - const lostAt = focusLostAtRef.current; - if (!lostAt) return; - const regainedAt = new Date().toISOString(); - const durationMs = new Date(regainedAt).getTime() - new Date(lostAt).getTime(); - focusLostAtRef.current = null; - fetch('/api/events/focus', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ session_id: sessionId, lost_at: lostAt, regained_at: regainedAt, duration_ms: durationMs }), - }).catch(() => {}); - } - - function handleVisibility() { - if (document.visibilityState === 'hidden') recordFocusLoss(); - else recordFocusRegain(); - } - - document.addEventListener('visibilitychange', handleVisibility); - window.addEventListener('blur', recordFocusLoss); - window.addEventListener('focus', recordFocusRegain); - return () => { - document.removeEventListener('visibilitychange', handleVisibility); - window.removeEventListener('blur', recordFocusLoss); - window.removeEventListener('focus', recordFocusRegain); - }; - }, [sessionId, isClosed]); - // ── Run code ────────────────────────────────────────────────────────────── async function runCode() { setRunning(true); diff --git a/app/participant/session/[id]/ResponseEditor.tsx b/app/participant/session/[id]/ResponseEditor.tsx index 487680f..cddb07f 100644 --- a/app/participant/session/[id]/ResponseEditor.tsx +++ b/app/participant/session/[id]/ResponseEditor.tsx @@ -173,32 +173,6 @@ export default function ResponseEditor({ prevLengthRef.current = target.value.length; }, []); - // ── Focus monitoring ────────────────────────────────────────────────────── - useEffect(() => { - if (isClosed || isFinal) return; - function recordLoss() { if (!focusLostAtRef.current) focusLostAtRef.current = new Date().toISOString(); } - function recordRegain() { - const lostAt = focusLostAtRef.current; - if (!lostAt) return; - const regainedAt = new Date().toISOString(); - focusLostAtRef.current = null; - fetch('/api/events/focus', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ session_id: sessionId, lost_at: lostAt, regained_at: regainedAt, duration_ms: new Date(regainedAt).getTime() - new Date(lostAt).getTime() }), - }).catch(() => {}); - } - const onVis = () => document.visibilityState === 'hidden' ? recordLoss() : recordRegain(); - document.addEventListener('visibilitychange', onVis); - window.addEventListener('blur', recordLoss); - window.addEventListener('focus', recordRegain); - return () => { - document.removeEventListener('visibilitychange', onVis); - window.removeEventListener('blur', recordLoss); - window.removeEventListener('focus', recordRegain); - }; - }, [sessionId, isClosed, isFinal]); - // ── Beforeunload ────────────────────────────────────────────────────────── useEffect(() => { const handler = (e: BeforeUnloadEvent) => { diff --git a/app/participant/session/[id]/SessionView.tsx b/app/participant/session/[id]/SessionView.tsx index fee7fb4..61b4d85 100644 --- a/app/participant/session/[id]/SessionView.tsx +++ b/app/participant/session/[id]/SessionView.tsx @@ -109,6 +109,49 @@ export default function SessionView({ exerciseId }: { exerciseId: string }) { // Local countdown — ticks every second, synced from server every 10s const [localRemaining, setLocalRemaining] = useState(null); + // Centralized focus tracking to prevent duplicates + useEffect(() => { + if (!sessionState || sessionClosed) return; + + let focusLostAt: number | null = null; + + const recordFocusLoss = () => { + if (focusLostAt !== null) return; // Already lost + focusLostAt = Date.now(); + }; + + const recordFocusRegain = async () => { + if (focusLostAt === null) return; // Wasn't lost + const duration = Date.now() - focusLostAt; + focusLostAt = null; + + try { + await fetch('/api/events/focus', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: sessionState.session_id, duration_ms: duration }), + }); + } catch (err) { + console.error('Failed to record focus event:', err); + } + }; + + const handleVisibility = () => { + if (document.visibilityState === 'hidden') recordFocusLoss(); + else recordFocusRegain(); + }; + + document.addEventListener('visibilitychange', handleVisibility); + window.addEventListener('blur', recordFocusLoss); + window.addEventListener('focus', recordFocusRegain); + + return () => { + document.removeEventListener('visibilitychange', handleVisibility); + window.removeEventListener('blur', recordFocusLoss); + window.removeEventListener('focus', recordFocusRegain); + }; + }, [sessionState, sessionClosed]); + const fetchSession = useCallback(async () => { try { const res = await fetch(`/api/exercises/${exerciseId}/session`); diff --git a/lib/flagging.ts b/lib/flagging.ts index 5bc4c10..a24c47e 100644 --- a/lib/flagging.ts +++ b/lib/flagging.ts @@ -21,12 +21,13 @@ const MIN_RESPONSE_LENGTH_FOR_EDIT_CHECK = parseInt(process.env.FLAG_MIN_RESPONS export async function evaluateFlags(submissionId: string): Promise { const flag_reasons: string[] = []; - // Look up the exercise's max_paste_chars threshold for this submission + // Look up the exercise's max_paste_chars threshold and question starter code for this submission const thresholdResult = await sql` - SELECT e.max_paste_chars, e.max_focus_loss, e.min_edit_events, e.min_response_length + SELECT e.max_paste_chars, e.max_focus_loss, e.min_edit_events, e.min_response_length, q.starter FROM submissions sub JOIN sessions sess ON sess.id = sub.session_id JOIN exercises e ON e.id = sess.exercise_id + JOIN questions q ON q.exercise_id = sess.exercise_id AND q.question_index = sub.question_index WHERE sub.id = ${submissionId} LIMIT 1 `; @@ -35,6 +36,7 @@ export async function evaluateFlags(submissionId: string): Promise { const focusLossThreshold = maxFocusLoss ?? FOCUS_LOSS_THRESHOLD; const minEditEvents = (thresholdResult[0]?.min_edit_events as number | null) ?? MIN_EDIT_EVENTS; const minResponseLength = (thresholdResult[0]?.min_response_length as number | null) ?? MIN_RESPONSE_LENGTH_FOR_EDIT_CHECK; + const starterCode: string = (thresholdResult[0]?.starter as string) ?? ''; // Count paste events that exceed the threshold (or all paste events if no threshold set) const pasteResult = maxPasteChars !== null @@ -71,7 +73,7 @@ export async function evaluateFlags(submissionId: string): Promise { ); } - // Count edit events and check response length + // Count edit events and check response length (excluding starter code) const editResult = await sql` SELECT COUNT(*)::int AS edit_count, s.response_text FROM edit_events ee @@ -83,13 +85,14 @@ export async function evaluateFlags(submissionId: string): Promise { if (editResult.length > 0) { const editCount: number = editResult[0].edit_count as number; const responseText: string = (editResult[0].response_text as string) ?? ''; + const addedLength = Math.max(0, responseText.length - starterCode.length); if ( - responseText.length > minResponseLength && + addedLength > minResponseLength && editCount < minEditEvents ) { flag_reasons.push( - `low_edit_count: only ${editCount} edit event(s) for a ${responseText.length}-character response` + `low_edit_count: only ${editCount} edit event(s) for a ${addedLength}-character response (excluding ${starterCode.length}-character starter code)` ); } } else { @@ -97,10 +100,11 @@ export async function evaluateFlags(submissionId: string): Promise { SELECT response_text FROM submissions WHERE id = ${submissionId} `; const responseText: string = (submissionResult[0]?.response_text as string) ?? ''; + const addedLength = Math.max(0, responseText.length - starterCode.length); - if (responseText.length > minResponseLength) { + if (addedLength > minResponseLength) { flag_reasons.push( - `low_edit_count: 0 edit event(s) for a ${responseText.length}-character response` + `low_edit_count: 0 edit event(s) for a ${addedLength}-character response (excluding ${starterCode.length}-character starter code)` ); } } diff --git a/lib/scoring.ts b/lib/scoring.ts index dba48b9..15e48d5 100644 --- a/lib/scoring.ts +++ b/lib/scoring.ts @@ -46,9 +46,14 @@ export async function recalculateSessionScore(sessionId: string): Promise 0 + OR EXISTS (SELECT 1 FROM edit_events ee WHERE ee.submission_id = submissions.id) + ) THEN 1 ELSE 0 END)::int AS count FROM submissions WHERE session_id = ${sessionId} `; From 64ae9decc99d087a9b501da5cec7978cb9f34453 Mon Sep 17 00:00:00 2001 From: jvcByte Date: Tue, 28 Apr 2026 13:49:41 +0100 Subject: [PATCH 2/3] Add participant feedback analysis and improvement plan - Document 4 participant feedback responses (avg 3.0/5 rating) - Identify 10 critical issues affecting platform usability - Create comprehensive design document for improvements - Prioritize fixes into 4 phases: Critical, UX, Flexibility, Testing - Focus on network resilience, navigation, and code execution stability --- .kiro/specs/feedback-improvements/design.md | 333 ++++++++++++++++++++ .kiro/specs/feedback-improvements/tasks.md | 312 ++++++++++++++++++ docs/feedback-analysis.md | 203 ++++++++++++ 3 files changed, 848 insertions(+) create mode 100644 .kiro/specs/feedback-improvements/design.md create mode 100644 .kiro/specs/feedback-improvements/tasks.md create mode 100644 docs/feedback-analysis.md diff --git a/.kiro/specs/feedback-improvements/design.md b/.kiro/specs/feedback-improvements/design.md new file mode 100644 index 0000000..e4241e5 --- /dev/null +++ b/.kiro/specs/feedback-improvements/design.md @@ -0,0 +1,333 @@ +# Design Document: Participant Feedback Improvements + +## Overview + +Based on participant feedback from April 21, 2026, this document outlines improvements to address critical platform issues affecting user experience and reliability. + +**Average Rating:** 3.0/5 (4 responses) +**Key Themes:** Reliability, Navigation, Flexibility, Testing + +--- + +## 1. Network Resilience & Autosave + +### Problem +Network interruptions cause loss of unsaved work despite autosave feature. + +### Requirements + +**1.1** Implement local storage backup before server sync +**1.2** Add offline queue for failed autosave requests +**1.3** Show clear save status: "Saving..." / "Saved" / "Offline" +**1.4** Retry failed saves when connection restored +**1.5** Reduce autosave interval from 10s to 3s +**1.6** Prevent navigation until current changes are saved +**1.7** Add "unsaved changes" warning before advancing + +### Design + +```typescript +// Local storage key pattern +const AUTOSAVE_KEY = `autosave:${sessionId}:${questionIndex}`; + +// Save status indicator +type SaveStatus = 'idle' | 'saving' | 'saved' | 'offline' | 'error'; + +// Autosave flow +1. User types → debounce 3s → save to localStorage +2. Attempt server sync +3. On success: clear localStorage, show "Saved" +4. On failure: keep in localStorage, show "Offline", add to retry queue +5. On reconnect: process retry queue +``` + +--- + +## 2. Non-Linear Question Navigation + +### Problem +Cannot skip questions and return later; questions lock after moving on. + +### Requirements + +**2.1** Add "Skip" button that advances without marking as final +**2.2** Allow navigation to any previously reached question +**2.3** Show question status: not started / draft / skipped / final +**2.4** Only lock questions explicitly marked as final +**2.5** Add "Edit" button to reopen draft/skipped questions +**2.6** Update progress dots to show all statuses + +### Design + +```typescript +// Question status enum +type QuestionStatus = 'not_started' | 'draft' | 'skipped' | 'final'; + +// Database schema addition +ALTER TABLE submissions ADD COLUMN status TEXT DEFAULT 'not_started'; + +// Navigation rules +- Can navigate to any question with index <= current_question_index +- "Skip" button: marks as 'skipped', advances current_question_index +- "Next" button: marks as 'draft' if has content, advances +- "Submit Final" button: marks as 'final', locks question +- Can edit any question with status != 'final' + +// Progress indicator +○ = not_started (gray) +◐ = draft (orange) +⊘ = skipped (yellow with slash) +● = final (green) +``` + +--- + +## 3. Question Overview Panel + +### Problem +Cannot see all questions upfront to plan approach. + +### Requirements + +**3.1** Add "Question Overview" toggle button +**3.2** Show list of all questions with title, type, status +**3.3** Allow clicking to jump to any reached question +**3.4** Show point value or difficulty if configured +**3.5** Maintain current one-at-a-time view as default + +### Design + +```typescript +// Overview panel component +interface QuestionOverview { + index: number; + title: string; + type: 'written' | 'code'; + status: QuestionStatus; + points?: number; + reachable: boolean; +} + +// UI layout +[Toggle Overview] button in header +→ Slides in side panel with question list +→ Click question → navigate if reachable +→ Shows lock icon for unreached questions +``` + +--- + +## 4. Improved Paste Detection + +### Problem +Accidental clipboard use triggers false positive cheating flags. + +### Requirements + +**4.1** Only flag pastes exceeding character threshold (already implemented) +**4.2** Track if user left page/tab before paste +**4.3** Distinguish internal (same page) vs external pastes +**4.4** Add paste event review UI for instructors +**4.5** Allow instructors to dismiss false positive paste flags + +### Design + +```typescript +// Enhanced paste event tracking +interface PasteEvent { + submission_id: string; + char_count: number; + pasted_text: string; + occurred_at: timestamp; + tab_was_blurred: boolean; // NEW + source_type: 'internal' | 'external' | 'unknown'; // NEW +} + +// Detection logic +1. Track last blur/focus events +2. On paste, check if blur occurred within 5s before paste +3. Compare pasted text with existing submission text to detect internal copy +4. Flag only if: char_count > threshold AND (tab_was_blurred OR source_type = 'external') +``` + +--- + +## 5. Code Execution Improvements + +### Problem +Cannot test code; compilation errors prevent verification. + +### Requirements + +**5.1** Add "Run Code" button with sample test cases +**5.2** Show clear error messages distinguishing platform vs code errors +**5.3** Ensure code runner stability throughout session +**5.4** Add syntax checking before submission +**5.5** Provide example inputs/outputs for testing + +### Design + +```typescript +// Test execution flow +1. User clicks "Run Code" +2. Send code + test cases to /api/run-code +3. Execute in sandboxed environment +4. Return: stdout, stderr, exit_code, execution_time +5. Display results in collapsible panel + +// Error categorization +- Syntax errors: "Your code has a syntax error on line X" +- Runtime errors: "Your code crashed: [error message]" +- Platform errors: "Platform error: [technical details]. Please report this." +- Timeout: "Execution exceeded 5 second limit" + +// Sample test cases in question +{ + "test_cases": [ + { "input": "hello", "expected_output": "HELLO" }, + { "input": "world", "expected_output": "WORLD" } + ] +} +``` + +--- + +## 6. Question Count Accuracy + +### Problem +UI shows incorrect number of answered questions. + +### Requirements + +**6.1** Audit question counting logic +**6.2** Count only includes submissions with status = 'final' +**6.3** Add unit tests for question status tracking +**6.4** Fix any caching issues in UI +**6.5** Real-time sync of question counts + +### Design + +```typescript +// Correct counting query +SELECT COUNT(*) FROM submissions +WHERE session_id = ? AND status = 'final' + +// UI state management +- Fetch question statuses on mount +- Update local state on any submission change +- Refetch on navigation to ensure sync +- Add React Query for automatic cache invalidation +``` + +--- + +## 7. Package/Library Flexibility + +### Problem +Forced to use specific packages instead of familiar alternatives. + +### Requirements + +**7.1** Allow multiple valid approaches unless testing specific package +**7.2** Update starter code to be more flexible +**7.3** Provide package documentation links in question +**7.4** Add note about allowed packages in question text + +### Design + +```typescript +// Question metadata +{ + "allowed_packages": ["os", "bufio", "io/ioutil"], // NEW + "required_package": null, // Only set if testing specific package + "documentation_links": [ // NEW + { "package": "os", "url": "https://pkg.go.dev/os" } + ] +} + +// Starter code template +// You may use any of: os, bufio, io/ioutil +// Documentation: [links] +package main + +func main() { + // Your solution here +} +``` + +--- + +## 8. Documentation Access + +### Problem +Need internet access for documentation lookup. + +### Requirements + +**8.1** Add built-in documentation viewer for allowed languages +**8.2** Whitelist official documentation sites +**8.3** Monitor for suspicious browsing patterns +**8.4** Provide offline documentation for common packages + +### Design + +```typescript +// Documentation panel +[Docs] button in editor toolbar +→ Opens side panel with iframe +→ Allowed domains: pkg.go.dev, golang.org, etc. +→ Track time spent on docs (not flagged) +→ Flag if navigating to non-whitelisted domains + +// Offline docs +- Bundle common package docs in platform +- Searchable documentation index +- Code examples for each function +``` + +--- + +## 9. Timeline & Scope Adjustment + +### Problem +3-month timeline too aggressive given platform state and learning curve. + +### Requirements + +**9.1** Conduct platform stability audit +**9.2** Comprehensive testing before next deployment +**9.3** Consider extending timeline or reducing scope +**9.4** Communicate realistic expectations to participants + +--- + +## Implementation Priority + +### Phase 1: Critical Fixes (Before Next Use) +- Network resilience & autosave improvements (1.1-1.7) +- Non-linear navigation & question unlocking (2.1-2.6) +- Question count accuracy (6.1-6.5) +- Code execution stability (5.2, 5.3) + +### Phase 2: UX Improvements +- Question overview panel (3.1-3.5) +- Improved paste detection (4.1-4.5) +- Code testing with sample cases (5.1, 5.4, 5.5) + +### Phase 3: Flexibility & Documentation +- Package flexibility (7.1-7.4) +- Documentation access (8.1-8.4) + +### Phase 4: Polish +- Timeline adjustment (9.1-9.4) +- Additional testing and refinement + +--- + +## Success Metrics + +- Average rating improves from 3.0 to 4.0+ +- Zero data loss incidents +- <5% false positive paste flags +- 100% code execution success rate +- Participant satisfaction with navigation flexibility diff --git a/.kiro/specs/feedback-improvements/tasks.md b/.kiro/specs/feedback-improvements/tasks.md new file mode 100644 index 0000000..95e0c3f --- /dev/null +++ b/.kiro/specs/feedback-improvements/tasks.md @@ -0,0 +1,312 @@ +# Implementation Plan: Feedback Improvements + +## Overview + +Addressing critical issues from participant feedback to improve platform reliability, navigation, and user experience. + +--- + +## Phase 1: Critical Fixes (Before Next Use) + +### Task 1: Network Resilience & Autosave + +- [ ] 1.1 Implement localStorage backup in CodeEditor + - Add `saveToLocalStorage(sessionId, questionIndex, code)` helper + - Call before server autosave attempt + - Clear on successful server save + - _Requirements: 1.1_ + +- [ ] 1.2 Implement localStorage backup in ResponseEditor + - Add `saveToLocalStorage(sessionId, questionIndex, text)` helper + - Call before server autosave attempt + - Clear on successful server save + - _Requirements: 1.1_ + +- [ ] 1.3 Add offline queue for failed autosaves + - Create `lib/offline-queue.ts` with retry logic + - Store failed requests in localStorage + - Process queue on reconnection + - _Requirements: 1.2, 1.4_ + +- [ ] 1.4 Add save status indicator component + - Create `SaveStatusIndicator.tsx` with states: idle, saving, saved, offline, error + - Show in editor toolbar + - Update based on autosave state + - _Requirements: 1.3_ + +- [ ] 1.5 Reduce autosave interval to 3 seconds + - Update debounce in CodeEditor from 10s to 3s + - Update debounce in ResponseEditor from 10s to 3s + - _Requirements: 1.5_ + +- [ ] 1.6 Add unsaved changes warning + - Track dirty state in editors + - Show warning modal before navigation if unsaved + - Prevent navigation until saved or user confirms + - _Requirements: 1.6, 1.7_ + +--- + +### Task 2: Non-Linear Navigation + +- [ ] 2.1 Add `status` column to submissions table + - Write migration `migrations/0009_submission_status.sql` + - Add `status TEXT DEFAULT 'not_started' CHECK (status IN ('not_started', 'draft', 'skipped', 'final'))` + - Backfill existing data: `'final'` if `is_final`, else `'draft'` if has content + - _Requirements: 2.3_ + +- [ ] 2.2 Update submission API to handle status + - Modify `/api/submissions/[sessionId]/autosave/route.ts` to set status = 'draft' + - Modify `/api/submissions/[sessionId]/final/route.ts` to set status = 'final' + - _Requirements: 2.4_ + +- [ ] 2.3 Add skip functionality to session advance API + - Modify `/api/exercises/[id]/session/advance/route.ts` + - Accept optional `{ skip: boolean }` in body + - If skip=true, set status = 'skipped' instead of 'draft' + - _Requirements: 2.1_ + +- [ ] 2.4 Update SessionView to allow navigation to reached questions + - Modify `app/participant/session/[id]/SessionView.tsx` + - Allow clicking progress dots for questions with index <= current_question_index + - Add `viewingIndex` state separate from `current_question_index` + - Show "Back to Current" button when viewing non-current question + - _Requirements: 2.2_ + +- [ ] 2.5 Add Skip button to SessionView + - Add "Skip Question" button next to "Next Question" + - Call advance API with `skip: true` + - Update UI to show skipped status + - _Requirements: 2.1_ + +- [ ] 2.6 Update progress dots to show all statuses + - Modify ProgressBar component in SessionView + - Use different colors/icons for: not_started, draft, skipped, final + - Add legend explaining status indicators + - _Requirements: 2.3, 2.6_ + +- [ ] 2.7 Allow editing non-final submissions + - Remove `isFinal` check that disables editors + - Only disable if status = 'final' + - Add "Reopen" button for final submissions (instructor only) + - _Requirements: 2.4, 2.5_ + +--- + +### Task 3: Question Count Accuracy + +- [ ] 3.1 Audit and fix question counting logic + - Review all queries that count submissions + - Ensure counting only status = 'final' + - Fix any caching issues + - _Requirements: 6.1, 6.2_ + +- [ ] 3.2 Add unit tests for question status tracking + - Test status transitions: not_started → draft → final + - Test status transitions: not_started → skipped → draft → final + - Test counting logic with various status combinations + - _Requirements: 6.3_ + +- [ ] 3.3 Add real-time sync of question counts + - Use React Query for automatic cache invalidation + - Refetch on navigation and submission changes + - _Requirements: 6.4, 6.5_ + +--- + +### Task 4: Code Execution Stability + +- [ ] 4.1 Improve error messages in code runner + - Modify `/api/run-code/route.ts` + - Categorize errors: syntax, runtime, platform, timeout + - Return structured error with category and user-friendly message + - _Requirements: 5.2_ + +- [ ] 4.2 Add error handling and retry logic + - Wrap code execution in try-catch + - Log platform errors for debugging + - Add automatic retry for transient failures + - _Requirements: 5.3_ + +- [ ] 4.3 Add syntax checking before submission + - Add pre-submission validation + - Show syntax errors before allowing final submission + - _Requirements: 5.4_ + +--- + +## Phase 2: UX Improvements + +### Task 5: Question Overview Panel + +- [ ] 5.1 Create QuestionOverview component + - Create `app/participant/session/[id]/QuestionOverview.tsx` + - Show list of all questions with title, type, status + - Allow clicking to navigate to reached questions + - _Requirements: 3.1, 3.2, 3.3_ + +- [ ] 5.2 Add toggle button to SessionView + - Add "Overview" button in header + - Slide in/out overview panel + - Maintain current view as default + - _Requirements: 3.5_ + +- [ ] 5.3 Show point values in overview + - Add `points` field to questions table (optional) + - Display in overview if configured + - _Requirements: 3.4_ + +--- + +### Task 6: Improved Paste Detection + +- [ ] 6.1 Add tab blur tracking to paste events + - Track last blur/focus events in SessionView + - Add `tab_was_blurred` field to paste_events table + - Include in paste event API request + - _Requirements: 4.2_ + +- [ ] 6.2 Add paste source detection + - Add `source_type` field to paste_events table + - Detect internal vs external pastes + - Compare pasted text with existing submission text + - _Requirements: 4.3_ + +- [ ] 6.3 Update paste flagging logic + - Modify `lib/flagging.ts` + - Only flag if: char_count > threshold AND (tab_was_blurred OR source_type = 'external') + - _Requirements: 4.1_ + +- [ ] 6.4 Add paste event review UI + - Enhance `FlagDismissal.tsx` to show paste context + - Show tab blur status and source type + - Allow instructors to dismiss false positives + - _Requirements: 4.4, 4.5_ + +--- + +### Task 7: Code Testing with Sample Cases + +- [ ] 7.1 Add test cases to questions + - Add `test_cases` JSONB field to questions table + - Store array of { input, expected_output } + - _Requirements: 5.5_ + +- [ ] 7.2 Add "Run Tests" button to CodeEditor + - Add button in editor toolbar + - Call `/api/run-code` with test cases + - Show results in collapsible panel + - _Requirements: 5.1_ + +- [ ] 7.3 Display test results + - Create TestResults component + - Show pass/fail for each test case + - Display stdout, stderr, execution time + - _Requirements: 5.1_ + +--- + +## Phase 3: Flexibility & Documentation + +### Task 8: Package Flexibility + +- [ ] 8.1 Add package metadata to questions + - Add `allowed_packages` JSONB field to questions table + - Add `required_package` TEXT field (nullable) + - Add `documentation_links` JSONB field + - _Requirements: 7.1, 7.3_ + +- [ ] 8.2 Update starter code templates + - Add comments about allowed packages + - Include documentation links + - Make templates more flexible + - _Requirements: 7.2, 7.4_ + +--- + +### Task 9: Documentation Access + +- [ ] 9.1 Create documentation viewer component + - Create `app/participant/session/[id]/DocsViewer.tsx` + - Embed iframe with whitelisted domains + - Add search functionality + - _Requirements: 8.1, 8.2_ + +- [ ] 9.2 Add documentation button to editor + - Add "Docs" button in toolbar + - Open docs panel in sidebar + - Track time spent (not flagged) + - _Requirements: 8.1_ + +- [ ] 9.3 Implement domain whitelist + - Create whitelist of allowed documentation sites + - Monitor navigation attempts + - Flag non-whitelisted domain access + - _Requirements: 8.2, 8.3_ + +- [ ] 9.4 Bundle offline documentation + - Package common language/package docs + - Create searchable index + - Serve from platform + - _Requirements: 8.4_ + +--- + +## Phase 4: Testing & Deployment + +### Task 10: Comprehensive Testing + +- [ ] 10.1 Platform stability audit + - Test all critical user flows + - Load testing with concurrent users + - Network failure scenarios + - _Requirements: 9.1_ + +- [ ] 10.2 Integration testing + - Test navigation flows + - Test autosave reliability + - Test code execution + - _Requirements: 9.2_ + +- [ ] 10.3 User acceptance testing + - Beta test with small group + - Gather feedback + - Iterate on issues + - _Requirements: 9.2_ + +--- + +### Task 11: Timeline & Communication + +- [ ] 11.1 Assess timeline feasibility + - Review remaining work + - Estimate completion dates + - Identify scope reduction options + - _Requirements: 9.3_ + +- [ ] 11.2 Communicate with participants + - Share improvement roadmap + - Set realistic expectations + - Provide timeline updates + - _Requirements: 9.4_ + +--- + +## Success Criteria + +- [ ] Zero data loss incidents in testing +- [ ] All critical bugs fixed +- [ ] Average rating target: 4.0+/5.0 +- [ ] <5% false positive paste flags +- [ ] 100% code execution success rate +- [ ] Positive feedback on navigation flexibility + +--- + +## Notes + +- Phase 1 tasks are critical and must be completed before next deployment +- Phase 2-3 tasks improve UX but are not blocking +- Phase 4 ensures quality and manages expectations +- Each task references specific requirements from design document +- Prioritize based on participant feedback severity diff --git a/docs/feedback-analysis.md b/docs/feedback-analysis.md new file mode 100644 index 0000000..6c3eba7 --- /dev/null +++ b/docs/feedback-analysis.md @@ -0,0 +1,203 @@ +# Participant Feedback Analysis + +**Date:** April 21, 2026 +**Total Responses:** 4 +**Average Rating:** 3.0/5 + +## Rating Distribution +- 5★: 0 +- 4★: 2 +- 3★: 0 +- 2★: 2 +- 1★: 0 + +--- + +## Critical Issues + +### 1. Network Interruptions Clear Unsaved Work +**Reporter:** jairiagb +**Severity:** High +**Description:** Network interruptions cause loss of unsaved work despite autosave feature. + +**Proposed Solution:** +- Implement local storage backup before sending to server +- Add offline queue for autosave requests +- Show clear "saving..." / "saved" / "offline" indicators +- Retry failed saves when connection restored + +--- + +### 2. Cannot Skip Questions and Return Later +**Reporters:** oayinmir, eokwong +**Severity:** High +**Description:** Participants want non-linear navigation to skip difficult questions and return later. + +**Current Behavior:** Linear progression only, must answer or leave blank before advancing. + +**Proposed Solution:** +- Add "Skip" button that advances without marking as final +- Show question status: not started / draft / skipped / final +- Allow navigation to any reached question via progress dots +- Only mark as final when explicitly submitted or session ends + +--- + +### 3. Copy/Paste Detection Too Strict +**Reporter:** oayinmir +**Severity:** Medium +**Description:** Accidental clipboard use (copying own code, redundant text) triggers cheating flags. + +**Current Behavior:** Any paste event is flagged regardless of context. + +**Proposed Solution:** +- Only flag pastes that exceed character threshold (already implemented) +- Track if user left the page/tab before paste +- Distinguish between internal (same page) and external pastes +- Add paste event review UI for instructors to dismiss false positives + +--- + +### 4. Cannot Test/Run Code & Compilation Errors +**Reporters:** oayinmir, eokwong +**Severity:** High +**Description:** Code won't compile/run, preventing verification of solutions. + +**Issues:** +- Compilation errors not from student's code +- Cannot test code before submission +- Platform stops compiling at some point + +**Proposed Solution:** +- Improve error messages to distinguish platform vs. code errors +- Add "Run Tests" button with sample inputs +- Ensure code runner is stable throughout session +- Add syntax checking before submission + +--- + +### 5. Inaccurate Question Count Display +**Reporter:** eokwong +**Severity:** Medium +**Description:** UI shows incorrect number of answered questions (showed 7 when only 4 answered). + +**Proposed Solution:** +- Audit question counting logic +- Ensure count only includes actually submitted answers +- Add unit tests for question status tracking +- Fix any caching issues in UI + +--- + +### 6. Cannot Edit After Skipping +**Reporter:** eokwong +**Severity:** High +**Description:** Questions become locked/uneditable after moving to next question. + +**Proposed Solution:** +- Allow editing any non-final submission +- Only lock questions explicitly marked as final +- Add "Edit" button to reopen locked questions +- Clear indication of which questions are editable vs. final + +--- + +### 7. Cannot See All Questions Upfront +**Reporter:** jairiagb +**Severity:** Medium +**Description:** Want to see all questions in a module to choose which ones to answer. + +**Current Behavior:** Questions revealed one at a time. + +**Proposed Solution:** +- Add "Question Overview" panel showing all question titles/types +- Allow clicking to jump to any question +- Show difficulty/point value for each question +- Maintain current one-at-a-time view as default with overview toggle + +--- + +### 8. Package/Library Restrictions Too Strict +**Reporter:** oayinmir +**Severity:** Medium +**Description:** Forced to use specific packages (e.g., bufio) instead of familiar ones (e.g., os.*). + +**Proposed Solution:** +- Allow multiple valid approaches unless specifically testing a package +- Update starter code to be more flexible +- Provide package documentation links in question +- Consider allowing internet access for docs + +--- + +### 9. Need Internet Access +**Reporter:** oayinmir +**Severity:** Medium +**Description:** Participants want internet access for documentation lookup. + +**Considerations:** +- Conflicts with anti-cheating measures +- Could allow restricted access to official docs only +- Or provide offline documentation viewer + +**Proposed Solution:** +- Add built-in documentation viewer for allowed languages/packages +- Whitelist official documentation sites +- Monitor for suspicious browsing patterns + +--- + +### 10. Autosave Delay Causes Data Loss +**Reporter:** oayinmir +**Severity:** High +**Description:** Input takes time to save; users assume it's saved and move on, losing work. + +**Proposed Solution:** +- Reduce autosave interval (currently 10s, reduce to 3-5s) +- Add visual "saving..." indicator +- Prevent navigation until current changes are saved +- Add "unsaved changes" warning before advancing + +--- + +## Positive Feedback + +- Autosave feature appreciated (jairiagb) +- Coding mentors' efforts recognized (eokwong) +- Platform concept is good, needs refinement + +--- + +## Action Items Priority + +### Immediate (Before Next Use) +1. Fix question locking issue - allow editing non-final submissions +2. Improve autosave reliability and reduce interval +3. Add offline/network failure handling +4. Fix question count display bug +5. Thorough platform testing + +### High Priority +1. Implement question skipping and non-linear navigation +2. Improve code compilation stability +3. Better error messages distinguishing platform vs. code errors +4. Add clear save status indicators + +### Medium Priority +1. Refine paste detection to reduce false positives +2. Add question overview panel +3. Relax package restrictions where appropriate +4. Consider documentation access solution + +### Low Priority +1. Add intellisense/autocomplete +2. Improve UI/UX polish +3. Add more comprehensive testing before deployment + +--- + +## Timeline Concern + +**Quote from jairiagb:** "Is three month not too short for us to start recoding 😏 Given our quite unique situation?" + +**Note:** Participants feel the 3-month timeline is too aggressive given the platform's current state and their learning curve. Consider extending timeline or reducing scope. From d581a61aa3b82b43ba7abd3d64e230756be96151 Mon Sep 17 00:00:00 2001 From: jvcByte Date: Wed, 29 Apr 2026 14:40:54 +0100 Subject: [PATCH 3/3] feat: feedback improvements - autosave resilience, non-linear nav, paste detection, docs viewer, test cases, scoring fixes --- .kiro/specs/feedback-improvements/tasks.md | 80 +++---- app/api/events/focus/route.ts | 16 +- app/api/events/paste/route.ts | 11 +- app/api/exercises/[id]/question/[n]/route.ts | 8 +- .../exercises/[id]/session/advance/route.ts | 45 ++-- app/api/exercises/[id]/session/route.ts | 3 +- app/api/instructor/users/[id]/route.ts | 24 ++ app/api/run-code/route.ts | 71 +++++- .../submissions/[sessionId]/autosave/route.ts | 7 +- .../submissions/[sessionId]/final/route.ts | 5 +- app/globals.css | 3 + .../exercises/[id]/QuestionManager.tsx | 74 +++++- app/instructor/submissions/[id]/page.tsx | 18 +- app/participant/session/[id]/CodeEditor.tsx | 200 ++++++++++++++-- app/participant/session/[id]/DocsViewer.tsx | 217 ++++++++++++++++++ .../session/[id]/QuestionOverview.tsx | 101 ++++++++ .../session/[id]/ResponseEditor.tsx | 95 ++++++-- .../session/[id]/SaveStatusIndicator.test.tsx | 42 ++++ .../session/[id]/SaveStatusIndicator.tsx | 85 +++++++ app/participant/session/[id]/SessionView.tsx | 137 +++++++++-- .../session/[id]/offline-docs/go-stdlib.ts | 81 +++++++ docs/participant-update.md | 46 ++++ docs/timeline-assessment.md | 37 +++ docs/uat-checklist.md | 62 +++++ lib/flagging.ts | 3 + lib/integration.test.ts | 167 ++++++++++++++ lib/offline-queue.README.md | 190 +++++++++++++++ lib/offline-queue.example.ts | 107 +++++++++ lib/offline-queue.test.ts | 211 +++++++++++++++++ lib/offline-queue.ts | 215 +++++++++++++++++ lib/question-status.test.ts | 133 +++++++++++ migrations/0009_submission_status.sql | 17 ++ migrations/0010_question_points.sql | 3 + migrations/0011_paste_event_context.sql | 7 + migrations/0012_question_test_cases.sql | 4 + migrations/0013_question_package_metadata.sql | 9 + tsconfig.tsbuildinfo | 2 +- 37 files changed, 2404 insertions(+), 132 deletions(-) create mode 100644 app/participant/session/[id]/DocsViewer.tsx create mode 100644 app/participant/session/[id]/QuestionOverview.tsx create mode 100644 app/participant/session/[id]/SaveStatusIndicator.test.tsx create mode 100644 app/participant/session/[id]/SaveStatusIndicator.tsx create mode 100644 app/participant/session/[id]/offline-docs/go-stdlib.ts create mode 100644 docs/participant-update.md create mode 100644 docs/timeline-assessment.md create mode 100644 docs/uat-checklist.md create mode 100644 lib/integration.test.ts create mode 100644 lib/offline-queue.README.md create mode 100644 lib/offline-queue.example.ts create mode 100644 lib/offline-queue.test.ts create mode 100644 lib/offline-queue.ts create mode 100644 lib/question-status.test.ts create mode 100644 migrations/0009_submission_status.sql create mode 100644 migrations/0010_question_points.sql create mode 100644 migrations/0011_paste_event_context.sql create mode 100644 migrations/0012_question_test_cases.sql create mode 100644 migrations/0013_question_package_metadata.sql diff --git a/.kiro/specs/feedback-improvements/tasks.md b/.kiro/specs/feedback-improvements/tasks.md index 95e0c3f..826d2a7 100644 --- a/.kiro/specs/feedback-improvements/tasks.md +++ b/.kiro/specs/feedback-improvements/tasks.md @@ -10,36 +10,36 @@ Addressing critical issues from participant feedback to improve platform reliabi ### Task 1: Network Resilience & Autosave -- [ ] 1.1 Implement localStorage backup in CodeEditor +- [x] 1.1 Implement localStorage backup in CodeEditor - Add `saveToLocalStorage(sessionId, questionIndex, code)` helper - Call before server autosave attempt - Clear on successful server save - _Requirements: 1.1_ -- [ ] 1.2 Implement localStorage backup in ResponseEditor +- [x] 1.2 Implement localStorage backup in ResponseEditor - Add `saveToLocalStorage(sessionId, questionIndex, text)` helper - Call before server autosave attempt - Clear on successful server save - _Requirements: 1.1_ -- [ ] 1.3 Add offline queue for failed autosaves +- [x] 1.3 Add offline queue for failed autosaves - Create `lib/offline-queue.ts` with retry logic - Store failed requests in localStorage - Process queue on reconnection - _Requirements: 1.2, 1.4_ -- [ ] 1.4 Add save status indicator component +- [x] 1.4 Add save status indicator component - Create `SaveStatusIndicator.tsx` with states: idle, saving, saved, offline, error - Show in editor toolbar - Update based on autosave state - _Requirements: 1.3_ -- [ ] 1.5 Reduce autosave interval to 3 seconds +- [x] 1.5 Reduce autosave interval to 3 seconds - Update debounce in CodeEditor from 10s to 3s - Update debounce in ResponseEditor from 10s to 3s - _Requirements: 1.5_ -- [ ] 1.6 Add unsaved changes warning +- [x] 1.6 Add unsaved changes warning - Track dirty state in editors - Show warning modal before navigation if unsaved - Prevent navigation until saved or user confirms @@ -49,43 +49,43 @@ Addressing critical issues from participant feedback to improve platform reliabi ### Task 2: Non-Linear Navigation -- [ ] 2.1 Add `status` column to submissions table +- [x] 2.1 Add `status` column to submissions table - Write migration `migrations/0009_submission_status.sql` - Add `status TEXT DEFAULT 'not_started' CHECK (status IN ('not_started', 'draft', 'skipped', 'final'))` - Backfill existing data: `'final'` if `is_final`, else `'draft'` if has content - _Requirements: 2.3_ -- [ ] 2.2 Update submission API to handle status +- [x] 2.2 Update submission API to handle status - Modify `/api/submissions/[sessionId]/autosave/route.ts` to set status = 'draft' - Modify `/api/submissions/[sessionId]/final/route.ts` to set status = 'final' - _Requirements: 2.4_ -- [ ] 2.3 Add skip functionality to session advance API +- [x] 2.3 Add skip functionality to session advance API - Modify `/api/exercises/[id]/session/advance/route.ts` - Accept optional `{ skip: boolean }` in body - If skip=true, set status = 'skipped' instead of 'draft' - _Requirements: 2.1_ -- [ ] 2.4 Update SessionView to allow navigation to reached questions +- [x] 2.4 Update SessionView to allow navigation to reached questions - Modify `app/participant/session/[id]/SessionView.tsx` - Allow clicking progress dots for questions with index <= current_question_index - Add `viewingIndex` state separate from `current_question_index` - Show "Back to Current" button when viewing non-current question - _Requirements: 2.2_ -- [ ] 2.5 Add Skip button to SessionView +- [x] 2.5 Add Skip button to SessionView - Add "Skip Question" button next to "Next Question" - Call advance API with `skip: true` - Update UI to show skipped status - _Requirements: 2.1_ -- [ ] 2.6 Update progress dots to show all statuses +- [x] 2.6 Update progress dots to show all statuses - Modify ProgressBar component in SessionView - Use different colors/icons for: not_started, draft, skipped, final - Add legend explaining status indicators - _Requirements: 2.3, 2.6_ -- [ ] 2.7 Allow editing non-final submissions +- [x] 2.7 Allow editing non-final submissions - Remove `isFinal` check that disables editors - Only disable if status = 'final' - Add "Reopen" button for final submissions (instructor only) @@ -95,19 +95,19 @@ Addressing critical issues from participant feedback to improve platform reliabi ### Task 3: Question Count Accuracy -- [ ] 3.1 Audit and fix question counting logic +- [x] 3.1 Audit and fix question counting logic - Review all queries that count submissions - Ensure counting only status = 'final' - Fix any caching issues - _Requirements: 6.1, 6.2_ -- [ ] 3.2 Add unit tests for question status tracking +- [x] 3.2 Add unit tests for question status tracking - Test status transitions: not_started → draft → final - Test status transitions: not_started → skipped → draft → final - Test counting logic with various status combinations - _Requirements: 6.3_ -- [ ] 3.3 Add real-time sync of question counts +- [x] 3.3 Add real-time sync of question counts - Use React Query for automatic cache invalidation - Refetch on navigation and submission changes - _Requirements: 6.4, 6.5_ @@ -116,19 +116,19 @@ Addressing critical issues from participant feedback to improve platform reliabi ### Task 4: Code Execution Stability -- [ ] 4.1 Improve error messages in code runner +- [x] 4.1 Improve error messages in code runner - Modify `/api/run-code/route.ts` - Categorize errors: syntax, runtime, platform, timeout - Return structured error with category and user-friendly message - _Requirements: 5.2_ -- [ ] 4.2 Add error handling and retry logic +- [x] 4.2 Add error handling and retry logic - Wrap code execution in try-catch - Log platform errors for debugging - Add automatic retry for transient failures - _Requirements: 5.3_ -- [ ] 4.3 Add syntax checking before submission +- [x] 4.3 Add syntax checking before submission - Add pre-submission validation - Show syntax errors before allowing final submission - _Requirements: 5.4_ @@ -139,19 +139,19 @@ Addressing critical issues from participant feedback to improve platform reliabi ### Task 5: Question Overview Panel -- [ ] 5.1 Create QuestionOverview component +- [x] 5.1 Create QuestionOverview component - Create `app/participant/session/[id]/QuestionOverview.tsx` - Show list of all questions with title, type, status - Allow clicking to navigate to reached questions - _Requirements: 3.1, 3.2, 3.3_ -- [ ] 5.2 Add toggle button to SessionView +- [x] 5.2 Add toggle button to SessionView - Add "Overview" button in header - Slide in/out overview panel - Maintain current view as default - _Requirements: 3.5_ -- [ ] 5.3 Show point values in overview +- [x] 5.3 Show point values in overview - Add `points` field to questions table (optional) - Display in overview if configured - _Requirements: 3.4_ @@ -160,24 +160,24 @@ Addressing critical issues from participant feedback to improve platform reliabi ### Task 6: Improved Paste Detection -- [ ] 6.1 Add tab blur tracking to paste events +- [x] 6.1 Add tab blur tracking to paste events - Track last blur/focus events in SessionView - Add `tab_was_blurred` field to paste_events table - Include in paste event API request - _Requirements: 4.2_ -- [ ] 6.2 Add paste source detection +- [x] 6.2 Add paste source detection - Add `source_type` field to paste_events table - Detect internal vs external pastes - Compare pasted text with existing submission text - _Requirements: 4.3_ -- [ ] 6.3 Update paste flagging logic +- [x] 6.3 Update paste flagging logic - Modify `lib/flagging.ts` - Only flag if: char_count > threshold AND (tab_was_blurred OR source_type = 'external') - _Requirements: 4.1_ -- [ ] 6.4 Add paste event review UI +- [x] 6.4 Add paste event review UI - Enhance `FlagDismissal.tsx` to show paste context - Show tab blur status and source type - Allow instructors to dismiss false positives @@ -187,18 +187,18 @@ Addressing critical issues from participant feedback to improve platform reliabi ### Task 7: Code Testing with Sample Cases -- [ ] 7.1 Add test cases to questions +- [x] 7.1 Add test cases to questions - Add `test_cases` JSONB field to questions table - Store array of { input, expected_output } - _Requirements: 5.5_ -- [ ] 7.2 Add "Run Tests" button to CodeEditor +- [x] 7.2 Add "Run Tests" button to CodeEditor - Add button in editor toolbar - Call `/api/run-code` with test cases - Show results in collapsible panel - _Requirements: 5.1_ -- [ ] 7.3 Display test results +- [x] 7.3 Display test results - Create TestResults component - Show pass/fail for each test case - Display stdout, stderr, execution time @@ -210,13 +210,13 @@ Addressing critical issues from participant feedback to improve platform reliabi ### Task 8: Package Flexibility -- [ ] 8.1 Add package metadata to questions +- [x] 8.1 Add package metadata to questions - Add `allowed_packages` JSONB field to questions table - Add `required_package` TEXT field (nullable) - Add `documentation_links` JSONB field - _Requirements: 7.1, 7.3_ -- [ ] 8.2 Update starter code templates +- [x] 8.2 Update starter code templates - Add comments about allowed packages - Include documentation links - Make templates more flexible @@ -226,25 +226,25 @@ Addressing critical issues from participant feedback to improve platform reliabi ### Task 9: Documentation Access -- [ ] 9.1 Create documentation viewer component +- [x] 9.1 Create documentation viewer component - Create `app/participant/session/[id]/DocsViewer.tsx` - Embed iframe with whitelisted domains - Add search functionality - _Requirements: 8.1, 8.2_ -- [ ] 9.2 Add documentation button to editor +- [x] 9.2 Add documentation button to editor - Add "Docs" button in toolbar - Open docs panel in sidebar - Track time spent (not flagged) - _Requirements: 8.1_ -- [ ] 9.3 Implement domain whitelist +- [x] 9.3 Implement domain whitelist - Create whitelist of allowed documentation sites - Monitor navigation attempts - Flag non-whitelisted domain access - _Requirements: 8.2, 8.3_ -- [ ] 9.4 Bundle offline documentation +- [x] 9.4 Bundle offline documentation - Package common language/package docs - Create searchable index - Serve from platform @@ -256,19 +256,19 @@ Addressing critical issues from participant feedback to improve platform reliabi ### Task 10: Comprehensive Testing -- [ ] 10.1 Platform stability audit +- [x] 10.1 Platform stability audit - Test all critical user flows - Load testing with concurrent users - Network failure scenarios - _Requirements: 9.1_ -- [ ] 10.2 Integration testing +- [x] 10.2 Integration testing - Test navigation flows - Test autosave reliability - Test code execution - _Requirements: 9.2_ -- [ ] 10.3 User acceptance testing +- [x] 10.3 User acceptance testing - Beta test with small group - Gather feedback - Iterate on issues @@ -278,13 +278,13 @@ Addressing critical issues from participant feedback to improve platform reliabi ### Task 11: Timeline & Communication -- [ ] 11.1 Assess timeline feasibility +- [x] 11.1 Assess timeline feasibility - Review remaining work - Estimate completion dates - Identify scope reduction options - _Requirements: 9.3_ -- [ ] 11.2 Communicate with participants +- [x] 11.2 Communicate with participants - Share improvement roadmap - Set realistic expectations - Provide timeline updates diff --git a/app/api/events/focus/route.ts b/app/api/events/focus/route.ts index bc7d01e..0951165 100644 --- a/app/api/events/focus/route.ts +++ b/app/api/events/focus/route.ts @@ -16,15 +16,23 @@ export async function POST(req: NextRequest) { const body = await req.json(); const { session_id, lost_at, regained_at, duration_ms } = body as { session_id: string; - lost_at: string; + lost_at?: string; regained_at?: string; duration_ms?: number; }; - if (!session_id || !lost_at) { + if (!session_id) { return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } + // Derive lost_at from duration_ms if not provided + const resolvedLostAt = lost_at ?? ( + duration_ms != null + ? new Date(Date.now() - duration_ms).toISOString() + : new Date().toISOString() + ); + const resolvedRegainedAt = regained_at ?? new Date().toISOString(); + // Validate session belongs to the current user const ownerCheck = await sql` SELECT id @@ -43,8 +51,8 @@ export async function POST(req: NextRequest) { INSERT INTO focus_events (session_id, lost_at, regained_at, duration_ms) VALUES ( ${session_id}, - ${lost_at}, - ${regained_at ?? null}, + ${resolvedLostAt}, + ${resolvedRegainedAt}, ${duration_ms ?? null} ) RETURNING id diff --git a/app/api/events/paste/route.ts b/app/api/events/paste/route.ts index bb35763..767b08e 100644 --- a/app/api/events/paste/route.ts +++ b/app/api/events/paste/route.ts @@ -14,11 +14,13 @@ export async function POST(req: NextRequest) { const userId = session.user.id; const body = await req.json(); - const { submission_id, char_count, pasted_text, occurred_at } = body as { + const { submission_id, char_count, pasted_text, occurred_at, tab_was_blurred, source_type } = body as { submission_id: string; char_count: number; pasted_text?: string | null; occurred_at: string; + tab_was_blurred?: boolean; + source_type?: 'internal' | 'external' | 'unknown'; }; if (!submission_id || char_count == null || !occurred_at) { @@ -41,8 +43,11 @@ export async function POST(req: NextRequest) { // Insert paste event const inserted = await sql` - INSERT INTO paste_events (submission_id, char_count, pasted_text, occurred_at) - VALUES (${submission_id}, ${char_count}, ${pasted_text ?? null}, ${occurred_at}) + INSERT INTO paste_events (submission_id, char_count, pasted_text, occurred_at, tab_was_blurred, source_type) + VALUES ( + ${submission_id}, ${char_count}, ${pasted_text ?? null}, ${occurred_at}, + ${tab_was_blurred ?? false}, ${source_type ?? 'unknown'} + ) RETURNING id `; diff --git a/app/api/exercises/[id]/question/[n]/route.ts b/app/api/exercises/[id]/question/[n]/route.ts index 37cefb2..94af978 100644 --- a/app/api/exercises/[id]/question/[n]/route.ts +++ b/app/api/exercises/[id]/question/[n]/route.ts @@ -45,7 +45,8 @@ export async function GET( // Load question from database const questionRows = await sql` - SELECT question_index, text, type, language, starter + SELECT question_index, text, type, language, starter, points, test_cases, + allowed_packages, required_package, documentation_links FROM questions WHERE exercise_id = ${exerciseId} AND question_index = ${questionIndex} @@ -64,5 +65,10 @@ export async function GET( type: q.type, language: q.language, starter: q.starter, + points: q.points ?? null, + test_cases: q.test_cases ?? null, + allowed_packages: q.allowed_packages ?? null, + required_package: q.required_package ?? null, + documentation_links: q.documentation_links ?? null, }); } diff --git a/app/api/exercises/[id]/session/advance/route.ts b/app/api/exercises/[id]/session/advance/route.ts index fff38bc..3802e5d 100644 --- a/app/api/exercises/[id]/session/advance/route.ts +++ b/app/api/exercises/[id]/session/advance/route.ts @@ -16,6 +16,13 @@ export async function POST( const userId = session.user.id; const exerciseId = params.id; + // Parse optional skip flag + let skip = false; + try { + const body = await _req.json().catch(() => ({})); + skip = !!(body as { skip?: boolean }).skip; + } catch { /* no body */ } + // Look up the session for this exercise + user const rows = await sql` SELECT s.id, s.closed_at, s.current_question_index, e.question_count @@ -47,18 +54,30 @@ export async function POST( ); } - // Lock the current question's submission by marking it final (only if actually attempted) - await sql` - UPDATE submissions - SET is_final = true - WHERE session_id = ${row.id} - AND question_index = ${currentIndex} - AND is_final = false - AND ( - LENGTH(TRIM(COALESCE(response_text, ''))) > 0 - OR EXISTS (SELECT 1 FROM edit_events ee WHERE ee.submission_id = submissions.id) - ) - `; + if (skip) { + // Mark current question as skipped (upsert so it works even without a draft) + await sql` + INSERT INTO submissions (session_id, question_index, response_text, submitted_at, status) + VALUES (${row.id}, ${currentIndex}, '', now(), 'skipped') + ON CONFLICT (session_id, question_index) + DO UPDATE SET status = 'skipped' + WHERE submissions.is_final = false + `; + } else { + // Lock the current question's submission by marking it final (only if actually attempted) + await sql` + UPDATE submissions + SET is_final = true, + status = 'final' + WHERE session_id = ${row.id} + AND question_index = ${currentIndex} + AND is_final = false + AND ( + LENGTH(TRIM(COALESCE(response_text, ''))) > 0 + OR EXISTS (SELECT 1 FROM edit_events ee WHERE ee.submission_id = submissions.id) + ) + `; + } // Advance to the next question const updated = await sql` @@ -70,5 +89,5 @@ export async function POST( const newIndex: number = updated[0].current_question_index as number; - return NextResponse.json({ new_index: newIndex }, { status: 200 }); + return NextResponse.json({ new_index: newIndex, skipped: skip }, { status: 200 }); } diff --git a/app/api/exercises/[id]/session/route.ts b/app/api/exercises/[id]/session/route.ts index 88e897f..76d3fa9 100644 --- a/app/api/exercises/[id]/session/route.ts +++ b/app/api/exercises/[id]/session/route.ts @@ -110,7 +110,7 @@ export async function GET( } const submissions = await sql` - SELECT question_index, is_final, + SELECT question_index, is_final, status, (response_text IS NOT NULL AND response_text <> '') AS has_draft FROM submissions WHERE session_id = ${row.id} `; @@ -126,6 +126,7 @@ export async function GET( question_index: s.question_index, has_draft: Boolean(s.has_draft), is_final: Boolean(s.is_final), + status: (s.status as string) ?? (s.is_final ? 'final' : s.has_draft ? 'draft' : 'not_started'), })), }); } diff --git a/app/api/instructor/users/[id]/route.ts b/app/api/instructor/users/[id]/route.ts index fdd7370..d763a44 100644 --- a/app/api/instructor/users/[id]/route.ts +++ b/app/api/instructor/users/[id]/route.ts @@ -21,6 +21,30 @@ export async function DELETE( if (rows[0].role === 'instructor') { return NextResponse.json({ error: 'Cannot delete instructor accounts' }, { status: 403 }); } + // Delete all dependent data before removing the user + await sql` + DELETE FROM paste_events WHERE submission_id IN ( + SELECT sub.id FROM submissions sub + JOIN sessions s ON s.id = sub.session_id WHERE s.user_id = ${userId} + ) + `; + await sql` + DELETE FROM autosave_history WHERE submission_id IN ( + SELECT sub.id FROM submissions sub + JOIN sessions s ON s.id = sub.session_id WHERE s.user_id = ${userId} + ) + `; + await sql` + DELETE FROM edit_events WHERE submission_id IN ( + SELECT sub.id FROM submissions sub + JOIN sessions s ON s.id = sub.session_id WHERE s.user_id = ${userId} + ) + `; + await sql`DELETE FROM submissions WHERE session_id IN (SELECT id FROM sessions WHERE user_id = ${userId})`; + await sql`DELETE FROM focus_events WHERE session_id IN (SELECT id FROM sessions WHERE user_id = ${userId})`; + await sql`DELETE FROM sessions WHERE user_id = ${userId}`; + await sql`DELETE FROM feedback WHERE user_id = ${userId}`; + await sql`DELETE FROM exercise_assignments WHERE user_id = ${userId}`; await sql`DELETE FROM users WHERE id = ${userId}`; await audit(session.user.id, 'user.deleted', 'user', userId, { username: rows[0].username }); return NextResponse.json({ message: 'User deleted' }); diff --git a/app/api/run-code/route.ts b/app/api/run-code/route.ts index ef4a2b9..a04aa99 100644 --- a/app/api/run-code/route.ts +++ b/app/api/run-code/route.ts @@ -58,7 +58,7 @@ export async function POST(req: NextRequest) { const effectiveStdin = stdin || (language === 'go' && BANNER_EXERCISE_SLUGS.has(exercise) ? getBannerContent() : ''); try { - const res = await fetch(`${RUNNER_URL}/run`, { + const res = await fetchWithRetry(`${RUNNER_URL}/run`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -70,7 +70,11 @@ export async function POST(req: NextRequest) { if (!res.ok) { const text = await res.text(); console.error('[run-code] Runner error:', text); - return NextResponse.json({ error: 'Execution service unavailable' }, { status: 502 }); + return NextResponse.json({ + error: 'Execution service unavailable', + error_category: 'platform', + error_message: 'Platform error: The execution service returned an error. Please try again.', + }, { status: 502 }); } const result = await res.json() as { @@ -80,9 +84,68 @@ export async function POST(req: NextRequest) { compile_output: string; }; - return NextResponse.json(result); + // Categorize errors for better user feedback + const categorized = categorizeRunResult(result); + return NextResponse.json(categorized); } catch (err) { console.error('[run-code] fetch error:', (err as Error).message); - return NextResponse.json({ error: 'Execution service unavailable' }, { status: 502 }); + return NextResponse.json({ + error: 'Execution service unavailable', + error_category: 'platform', + error_message: 'Platform error: Could not reach the code execution service. Please try again or report this issue.', + }, { status: 502 }); + } +} + +type RunResult = { + stdout: string; + stderr: string; + exit_code: number; + compile_output: string; + error_category?: 'syntax' | 'runtime' | 'timeout' | 'platform'; + error_message?: string; +}; + +async function fetchWithRetry(url: string, options: RequestInit, maxRetries = 2): Promise { + let lastError: Error | null = null; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const res = await fetch(url, options); + // Only retry on 5xx transient errors, not 4xx + if (res.status >= 500 && attempt < maxRetries) { + console.warn(`[run-code] Attempt ${attempt + 1} failed with ${res.status}, retrying…`); + await new Promise((r) => setTimeout(r, 500 * (attempt + 1))); + continue; + } + return res; + } catch (err) { + lastError = err as Error; + if (attempt < maxRetries) { + console.warn(`[run-code] Attempt ${attempt + 1} network error, retrying…`); + await new Promise((r) => setTimeout(r, 500 * (attempt + 1))); + } + } } + throw lastError ?? new Error('Max retries exceeded'); +} + +function categorizeRunResult(result: RunResult): RunResult { + const { stderr, compile_output, exit_code } = result; + + // Timeout + if (exit_code === 124 || (stderr && /time limit|timed out|killed/i.test(stderr))) { + return { ...result, error_category: 'timeout', error_message: 'Execution exceeded the 5 second time limit.' }; + } + + // Compile/syntax error + if (compile_output && compile_output.trim().length > 0) { + return { ...result, error_category: 'syntax', error_message: 'Your code has a syntax or compile error. Check the compile output below.' }; + } + + // Runtime error (non-zero exit, stderr present) + if (exit_code !== 0 && stderr && stderr.trim().length > 0) { + return { ...result, error_category: 'runtime', error_message: `Your code crashed with exit code ${exit_code}.` }; + } + + return result; } diff --git a/app/api/submissions/[sessionId]/autosave/route.ts b/app/api/submissions/[sessionId]/autosave/route.ts index fa267cd..ea6e780 100644 --- a/app/api/submissions/[sessionId]/autosave/route.ts +++ b/app/api/submissions/[sessionId]/autosave/route.ts @@ -77,12 +77,13 @@ export async function POST( // Upsert the submission const upserted = await sql` - INSERT INTO submissions (session_id, question_index, response_text, submitted_at) - VALUES (${sessionId}, ${question_index}, ${response_text}, now()) + INSERT INTO submissions (session_id, question_index, response_text, submitted_at, status) + VALUES (${sessionId}, ${question_index}, ${response_text}, now(), 'draft') ON CONFLICT (session_id, question_index) DO UPDATE SET response_text = EXCLUDED.response_text, - submitted_at = EXCLUDED.submitted_at + submitted_at = EXCLUDED.submitted_at, + status = 'draft' RETURNING id `; diff --git a/app/api/submissions/[sessionId]/final/route.ts b/app/api/submissions/[sessionId]/final/route.ts index 1c0d779..e4cbdf2 100644 --- a/app/api/submissions/[sessionId]/final/route.ts +++ b/app/api/submissions/[sessionId]/final/route.ts @@ -65,7 +65,8 @@ export async function POST( // Mark as final await sql` UPDATE submissions - SET is_final = true + SET is_final = true, + status = 'final' WHERE id = ${submission.id} `; @@ -82,7 +83,7 @@ export async function POST( const finalCountRows = await sql` SELECT COUNT(*)::int AS count FROM submissions - WHERE session_id = ${sessionId} AND is_final = true + WHERE session_id = ${sessionId} AND status = 'final' `; const finalCount = (finalCountRows[0]?.count as number) ?? 0; diff --git a/app/globals.css b/app/globals.css index ecb2143..99811a7 100644 --- a/app/globals.css +++ b/app/globals.css @@ -437,6 +437,9 @@ tbody tr:hover td { color: var(--text); } animation: pulse 1.2s ease-in-out infinite; } @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.55; } } +@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } + +.animate-spin { animation: spin 1s linear infinite; } /* ── Login ── */ .login-page { diff --git a/app/instructor/exercises/[id]/QuestionManager.tsx b/app/instructor/exercises/[id]/QuestionManager.tsx index 005742c..5d05f72 100644 --- a/app/instructor/exercises/[id]/QuestionManager.tsx +++ b/app/instructor/exercises/[id]/QuestionManager.tsx @@ -3,7 +3,7 @@ import { useState, useRef } from 'react'; import ReactMarkdown from 'react-markdown'; import { toast } from 'sonner'; -import { Plus, Trash2, Edit3, ChevronDown, ChevronUp, Save, X, Upload, FileText, RefreshCw } from 'lucide-react'; +import { Plus, Trash2, Edit3, ChevronDown, ChevronUp, Save, X, Upload, FileText, RefreshCw, AlertTriangle } from 'lucide-react'; interface Question { id: string; @@ -37,10 +37,14 @@ export default function QuestionManager({ exerciseId, exerciseSlug, initialQuest const [newQ, setNewQ] = useState({ text: '', type: defaults.type, language: defaults.language, starter: '' }); const [saving, setSaving] = useState(false); const [uploading, setUploading] = useState(false); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + const [deleting, setDeleting] = useState(false); + const [confirmSync, setConfirmSync] = useState(false); const [syncing, setSyncing] = useState(false); - const [uploadType, setUploadType] = useState(defaults.type); const [uploadLang, setUploadLang] = useState(defaults.language); + const [uploadType, setUploadType] = useState(defaults.type); const [dragOver, setDragOver] = useState(false); + const fileInputRef = useRef(null); async function handleUpload(e: React.ChangeEvent) { @@ -71,8 +75,8 @@ export default function QuestionManager({ exerciseId, exerciseSlug, initialQuest } async function syncFromFiles() { - if (!confirm('Sync questions from docs files? This will replace all current questions.')) return; setSyncing(true); + setConfirmSync(false); try { const res = await fetch(`/api/instructor/exercises/${exerciseId}/questions/sync`, { method: 'POST' }); const data = await res.json(); @@ -110,15 +114,18 @@ export default function QuestionManager({ exerciseId, exerciseSlug, initialQuest } finally { setSaving(false); } } - async function deleteQuestion(id: string, index: number) { - if (!confirm(`Delete question ${index + 1}? This cannot be undone.`)) return; + async function deleteQuestion(id: string) { + setDeleting(true); try { const res = await fetch(`/api/instructor/exercises/${exerciseId}/questions/${id}`, { method: 'DELETE' }); if (!res.ok) throw new Error(await res.text()); setQuestions((qs) => qs.filter((q) => q.id !== id).map((q, i) => ({ ...q, question_index: i }))); + setConfirmDeleteId(null); toast.success('Question deleted'); } catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Failed to delete'); + } finally { + setDeleting(false); } } @@ -182,12 +189,33 @@ export default function QuestionManager({ exerciseId, exerciseSlug, initialQuest - + {confirmDeleteId === q.id && ( +
+
+ + + Delete Question {q.question_index + 1}? This cannot be undone. + + + +
+
+ )} + {expanded === q.id && (
{editing === q.id ? ( @@ -250,9 +278,22 @@ export default function QuestionManager({ exerciseId, exerciseSlug, initialQuest
Bulk Import from Markdown
- + {confirmSync ? ( + <> + + Replace all questions? + + + + ) : ( + + )} Replaces all questions
@@ -336,7 +377,20 @@ export default function QuestionManager({ exerciseId, exerciseSlug, initialQuest {newQ.type === 'code' && (
-