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..826d2a7 --- /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 + +- [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_ + +- [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_ + +- [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_ + +- [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_ + +- [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_ + +- [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 + - _Requirements: 1.6, 1.7_ + +--- + +### Task 2: Non-Linear Navigation + +- [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_ + +- [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_ + +- [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_ + +- [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_ + +- [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_ + +- [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_ + +- [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) + - _Requirements: 2.4, 2.5_ + +--- + +### Task 3: Question Count Accuracy + +- [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_ + +- [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_ + +- [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_ + +--- + +### Task 4: Code Execution Stability + +- [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_ + +- [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_ + +- [x] 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 + +- [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_ + +- [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_ + +- [x] 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 + +- [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_ + +- [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_ + +- [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_ + +- [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 + - _Requirements: 4.4, 4.5_ + +--- + +### Task 7: Code Testing with Sample Cases + +- [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_ + +- [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_ + +- [x] 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 + +- [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_ + +- [x] 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 + +- [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_ + +- [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_ + +- [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_ + +- [x] 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 + +- [x] 10.1 Platform stability audit + - Test all critical user flows + - Load testing with concurrent users + - Network failure scenarios + - _Requirements: 9.1_ + +- [x] 10.2 Integration testing + - Test navigation flows + - Test autosave reliability + - Test code execution + - _Requirements: 9.2_ + +- [x] 10.3 User acceptance testing + - Beta test with small group + - Gather feedback + - Iterate on issues + - _Requirements: 9.2_ + +--- + +### Task 11: Timeline & Communication + +- [x] 11.1 Assess timeline feasibility + - Review remaining work + - Estimate completion dates + - Identify scope reduction options + - _Requirements: 9.3_ + +- [x] 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/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 ae8cd6f..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,14 +54,30 @@ export async function POST( ); } - // Lock the current question's submission by marking it final - await sql` - UPDATE submissions - SET is_final = true - WHERE session_id = ${row.id} - AND question_index = ${currentIndex} - AND is_final = false - `; + 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` @@ -66,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/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/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' && (
-