From e3c05999ea98fe80cad6c57df1047bebc2b55bb2 Mon Sep 17 00:00:00 2001 From: jvcByte Date: Mon, 27 Apr 2026 13:54:38 +0100 Subject: [PATCH 1/2] Platform improvements: scoring, flag dismissal, WAT timestamps, analytics - Paste detector: capture exact pasted text via Monaco getValueInRange, store in paste_events.pasted_text - Auto-grading: score = (final_submissions/total_questions)*100 - active_flag_reasons*FLAG_PENALTY, stored on sessions.score - Pass/fail constraints per exercise: pass_mark, min_questions_required, flag_fails, max_paste_chars, max_focus_loss, min_edit_events, min_response_length - Flag dismissal: instructors can dismiss/restore individual flag reasons with audit trail (dismissed_flags JSONB on submissions) - Score recalculation: bulk recalculate-scores endpoint re-evaluates flags and scores for all sessions in an exercise - Submit Final Answer button on last question; session auto-closes and scores on finalisation - WAT timezone: lib/format.ts with formatWAT/formatDateWAT/formatTimeWAT (Africa/Lagos, 12hr), applied across all instructor UI timestamps - Submissions table: aggregated per-participant view with expandable question rows, progress, score, pass/fail, flags - Participant dashboard: score badge with pass/fail indicator and specific fail reasons per exercise - Cohort analytics on instructor dashboard: summary tiles + per-exercise table with table/chart toggle (recharts bar charts for pass/fail/flagged and avg score) - Migration 0008: adds pasted_text, pass_mark, min_questions_required, flag_fails, max_paste_chars, max_focus_loss, min_edit_events, min_response_length, score, passed, dismissed_flags columns --- .../specs/platform-improvements/.config.kiro | 4 + .kiro/specs/platform-improvements/design.md | 584 ++++++++++++++++++ .../platform-improvements/requirements.md | 111 ++++ .kiro/specs/platform-improvements/tasks.md | 197 ++++++ app/api/events/paste/route.ts | 7 +- .../[id]/recalculate-scores/route.ts | 209 +++++++ app/api/instructor/exercises/[id]/route.ts | 56 +- .../submissions/[id]/dismiss-flag/route.ts | 118 ++++ .../submissions/[sessionId]/final/route.ts | 26 + app/instructor/AnalyticsPanel.tsx | 221 +++++++ app/instructor/LiveMonitor.tsx | 6 +- .../exercises/[id]/ExerciseManager.tsx | 176 +++++- app/instructor/exercises/[id]/page.tsx | 27 +- .../[id]/submissions/SubmissionsTable.tsx | 145 +++-- .../exercises/[id]/submissions/page.tsx | 93 ++- app/instructor/feedback/page.tsx | 4 +- app/instructor/page.tsx | 62 +- .../submissions/[id]/FlagDismissal.tsx | 108 ++++ app/instructor/submissions/[id]/page.tsx | 40 +- app/instructor/users/UserManager.tsx | 4 +- app/participant/page.tsx | 120 +++- app/participant/session/[id]/CodeEditor.tsx | 9 +- app/participant/session/[id]/SessionView.tsx | 38 ++ lib/flagging.ts | 52 +- lib/format.ts | 84 +++ lib/scoring.ts | 117 ++++ migrations/0008_platform_improvements.sql | 19 + package-lock.json | 445 +++++++++++-- package.json | 1 + 29 files changed, 2891 insertions(+), 192 deletions(-) create mode 100644 .kiro/specs/platform-improvements/.config.kiro create mode 100644 .kiro/specs/platform-improvements/design.md create mode 100644 .kiro/specs/platform-improvements/requirements.md create mode 100644 .kiro/specs/platform-improvements/tasks.md create mode 100644 app/api/instructor/exercises/[id]/recalculate-scores/route.ts create mode 100644 app/api/instructor/submissions/[id]/dismiss-flag/route.ts create mode 100644 app/instructor/AnalyticsPanel.tsx create mode 100644 app/instructor/submissions/[id]/FlagDismissal.tsx create mode 100644 lib/format.ts create mode 100644 lib/scoring.ts create mode 100644 migrations/0008_platform_improvements.sql diff --git a/.kiro/specs/platform-improvements/.config.kiro b/.kiro/specs/platform-improvements/.config.kiro new file mode 100644 index 0000000..cf5fe2e --- /dev/null +++ b/.kiro/specs/platform-improvements/.config.kiro @@ -0,0 +1,4 @@ +{ + "specType": "feature", + "workflowType": "requirements-first" +} diff --git a/.kiro/specs/platform-improvements/design.md b/.kiro/specs/platform-improvements/design.md new file mode 100644 index 0000000..075bc0d --- /dev/null +++ b/.kiro/specs/platform-improvements/design.md @@ -0,0 +1,584 @@ +# Design Document — Platform Improvements + +## Overview + +This document covers the technical design for four improvements to the recoding platform: + +1. **Paste detector fix** — capture exact pasted text, not just char count +2. **Auto-grading** — score calculation, threshold configuration, and score display +3. **Flag dismissal** — per-reason dismissal with audit trail and score recalculation +4. **Timezone display** — unified WAT (UTC+1) formatting utility across all UI timestamps + +All changes are additive migrations; no existing data is destroyed. + +--- + +## Architecture + +The platform is a Next.js 14 app-router application backed by PostgreSQL (accessed via the `sql` tagged-template helper from `lib/db`). The relevant layers are: + +``` +Browser (React / Monaco) + │ + ▼ +Next.js API Routes ──► lib/flagging.ts + ──► lib/scoring.ts (new) + ──► lib/format.ts (new) + ──► PostgreSQL +``` + +Score recalculation is synchronous and in-process (no background queue needed at this scale). All mutations that affect a score call `recalculateSessionScore(sessionId)` from `lib/scoring.ts` before returning. + +--- + +## Components and Interfaces + +### New / modified files + +| File | Change | +|---|---| +| `lib/format.ts` | New — `formatWAT` utility | +| `lib/scoring.ts` | New — `recalculateSessionScore` | +| `lib/flagging.ts` | Modified — exclude dismissed flags | +| `app/api/events/paste/route.ts` | Modified — accept + store `pasted_text` | +| `app/api/instructor/exercises/[id]/route.ts` | Modified — accept `pass_mark` | +| `app/api/instructor/submissions/[id]/dismiss-flag/route.ts` | New | +| `app/api/submissions/[sessionId]/final/route.ts` | Modified — trigger score calc | +| `app/participant/page.tsx` | Modified — show score badge | +| `app/participant/session/[id]/CodeEditor.tsx` | Modified — send `pasted_text` | +| `app/instructor/exercises/[id]/ExerciseManager.tsx` | Modified — pass mark input | +| `app/instructor/exercises/[id]/submissions/SubmissionsTable.tsx` | Modified — score column | +| `app/instructor/submissions/[id]/page.tsx` | Modified — dismiss UI, WAT timestamps, pasted text | +| All timestamp display sites | Modified — use `formatWAT` | + +--- + +## Data Models + +### Migration 0008 — platform improvements + +```sql +-- 1. Paste text capture +ALTER TABLE paste_events + ADD COLUMN pasted_text TEXT; + +-- 2. Pass mark threshold on exercises +ALTER TABLE exercises + ADD COLUMN pass_mark NUMERIC; -- nullable; NULL = no threshold + +-- 3. Score on sessions +ALTER TABLE sessions + ADD COLUMN score NUMERIC; -- nullable until first calculation + +-- 4. Flag dismissal audit on submissions +ALTER TABLE submissions + ADD COLUMN dismissed_flags JSONB NOT NULL DEFAULT '[]'::jsonb; + -- shape: [{ reason: string, dismissed_by: string (user id), dismissed_at: string (ISO) }] +``` + +### Shape of `dismissed_flags` + +```ts +type DismissedFlag = { + reason: string; // matches a string in flag_reasons[] + dismissed_by: string; // instructor user id + dismissed_at: string; // ISO 8601 UTC +}; +``` + +The original `flag_reasons TEXT[]` column is never modified after a dismissal — it is the permanent audit record. `dismissed_flags` is the mutable overlay. + +--- + +## `lib/format.ts` — WAT Formatting Utility + +```ts +/** + * Format an ISO timestamp string (or Date) in West Africa Time (UTC+1). + * Uses the 'Africa/Lagos' IANA timezone which is permanently UTC+1. + * + * @param iso - ISO 8601 string or Date object + * @param opts - Intl.DateTimeFormatOptions overrides (optional) + * @returns - Formatted string, e.g. "12/06/2025, 14:30:00 WAT" + */ +export function formatWAT( + iso: string | Date, + opts?: Intl.DateTimeFormatOptions +): string { + const date = typeof iso === 'string' ? new Date(iso) : iso; + const base: Intl.DateTimeFormatOptions = { + timeZone: 'Africa/Lagos', + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + hour12: false, + ...opts, + }; + return date.toLocaleString('en-NG', base) + ' WAT'; +} + +/** Date-only variant — no time component, no WAT suffix */ +export function formatDateWAT( + iso: string | Date, + opts?: Intl.DateTimeFormatOptions +): string { + const date = typeof iso === 'string' ? new Date(iso) : iso; + return date.toLocaleDateString('en-NG', { + timeZone: 'Africa/Lagos', + year: 'numeric', month: '2-digit', day: '2-digit', + ...opts, + }); +} +``` + +All existing `new Date(x).toLocaleString()`, `toLocaleTimeString()`, and `toLocaleDateString()` calls in UI files are replaced with `formatWAT(x)` or `formatDateWAT(x)`. + +Affected call sites: +- `app/instructor/exercises/[id]/submissions/SubmissionsTable.tsx` — `submitted_at` +- `app/instructor/submissions/[id]/page.tsx` — `submitted_at`, paste `occurred_at`, focus `lost_at` / `regained_at` +- `app/instructor/feedback/page.tsx` — feedback `submitted_at` +- `app/instructor/users/UserManager.tsx` — user `created_at` +- `app/instructor/LiveMonitor.tsx` — heartbeat timestamps +- Any other display-layer timestamp usage + +--- + +## Score Calculation Algorithm + +### `lib/scoring.ts` + +```ts +const FLAG_PENALTY = parseFloat(process.env.FLAG_PENALTY ?? '10'); + +/** + * Recalculates and persists the score for a session. + * + * score = (finalSubmissions / totalQuestions) * 100 + * - (activeFlagReasonCount * FLAG_PENALTY) + * floored at 0, capped at 100. + * + * "Active" flag reasons = flag_reasons that do NOT appear in dismissed_flags. + */ +export async function recalculateSessionScore(sessionId: string): Promise { + // 1. Get exercise question count + const sessionRow = await sql` + SELECT s.id, e.question_count + FROM sessions s + JOIN exercises e ON e.id = s.exercise_id + WHERE s.id = ${sessionId} + LIMIT 1 + `; + if (sessionRow.length === 0) throw new Error('Session not found'); + const totalQuestions: number = sessionRow[0].question_count as number; + + // 2. Count final submissions for this session + const finalRow = await sql` + SELECT COUNT(*)::int AS count + FROM submissions + WHERE session_id = ${sessionId} AND is_final = true + `; + const finalCount: number = (finalRow[0]?.count as number) ?? 0; + + // 3. Count active (non-dismissed) flag reasons across all submissions + const flagRow = await sql` + SELECT + COALESCE(array_length(flag_reasons, 1), 0) AS total_reasons, + jsonb_array_length(dismissed_flags) AS dismissed_count + FROM submissions + WHERE session_id = ${sessionId} + `; + + let activeFlagReasonCount = 0; + for (const row of flagRow) { + const total = (row.total_reasons as number) ?? 0; + const dismissed = (row.dismissed_count as number) ?? 0; + activeFlagReasonCount += Math.max(0, total - dismissed); + } + + // 4. Formula + const raw = (finalCount / totalQuestions) * 100 - activeFlagReasonCount * FLAG_PENALTY; + const score = Math.min(100, Math.max(0, raw)); + + // 5. Persist + await sql` + UPDATE sessions SET score = ${score} WHERE id = ${sessionId} + `; + + return score; +} +``` + +**Recalculation triggers:** +- Session close (`POST /api/submissions/[sessionId]/final` when last question is finalised and session closes) +- Flag dismissal (`PUT /api/instructor/submissions/[id]/dismiss-flag`) +- Flag restore (same endpoint with `restore: true`) +- Pass mark update (`PUT /api/instructor/exercises/[id]` with `pass_mark`) — recalculates all sessions for that exercise + +--- + +## New and Modified API Routes + +### 1. `POST /api/events/paste` — modified + +**Request body** (adds `pasted_text`): +```ts +{ + submission_id: string; + char_count: number; + pasted_text: string; // NEW — exact text from getValueInRange + occurred_at: string; // ISO 8601 +} +``` + +**Change:** Insert `pasted_text` into `paste_events`. All other behaviour unchanged. + +--- + +### 2. `PUT /api/instructor/exercises/[id]` — modified + +**Request body** (adds `pass_mark`): +```ts +{ + enabled?: boolean; + assign_user_ids?: string[]; + start_time?: string | null; + end_time?: string | null; + duration_limit?: string | null; + pass_mark?: number | null; // NEW — non-negative or null to clear +} +``` + +**Change:** When `pass_mark` is present, validate `>= 0`, persist to `exercises.pass_mark`, then call `recalculateSessionScore` for every session belonging to this exercise. + +--- + +### 3. `PUT /api/instructor/submissions/[id]/dismiss-flag` — new + +**Auth:** instructor only + +**Request body:** +```ts +{ + reason: string; // must match a string in flag_reasons[] + restore?: boolean; // if true, removes the dismissal instead +} +``` + +**Response (200):** +```ts +{ + submission_id: string; + dismissed_flags: DismissedFlag[]; + is_flagged: boolean; + score: number; // updated session score +} +``` + +**Logic:** +1. Load submission, verify `reason` exists in `flag_reasons`. +2. If `restore: false` (default): append `{ reason, dismissed_by: instructorId, dismissed_at: now() }` to `dismissed_flags` if not already present. +3. If `restore: true`: remove the entry with matching `reason` from `dismissed_flags`. +4. Recompute `is_flagged`: `flag_reasons.some(r => !dismissed_flags.find(d => d.reason === r))`. +5. Update `submissions` row. +6. Call `recalculateSessionScore(sessionId)`. +7. Audit log the action. + +**Error responses:** +- `401` — not authenticated +- `403` — not instructor +- `404` — submission not found +- `400` — reason not in flag_reasons, or already dismissed / not dismissed + +--- + +### 4. `POST /api/submissions/[sessionId]/final` — modified + +After marking a submission as final, check if all questions for the session are now final. If so, close the session (`closed_at = now()`) and call `recalculateSessionScore(sessionId)`. + +--- + +## Component Changes + +### `app/participant/session/[id]/CodeEditor.tsx` + +In the `onDidPaste` handler, extract the pasted text and include it in the API call: + +```ts +editor.onDidPaste((e) => { + const model = editor.getModel(); + const pastedText = e.range ? (model?.getValueInRange(e.range) ?? '') : ''; + const charCount = pastedText.length; + // ... + body: JSON.stringify({ + submission_id: submissionId, + char_count: charCount, + pasted_text: pastedText, // NEW + occurred_at: occurredAt, + }), +}); +``` + +### `app/instructor/exercises/[id]/ExerciseManager.tsx` + +Add a "Scoring" card with a pass mark input: + +```tsx +// New state +const [passMark, setPassMark] = useState( + initial.pass_mark != null ? String(initial.pass_mark) : '' +); + +// New card in the JSX +
+
Scoring
+

+ Set a pass mark (0–100). Leave blank for no threshold. +

+
+ + setPassMark(e.target.value)} + /> +
+ +
+``` + +The `Exercise` interface gains `pass_mark: number | null`. + +### `app/instructor/exercises/[id]/submissions/SubmissionsTable.tsx` + +- Add `score: number | null` and `pass_mark: number | null` to `SubmissionRow` (pass_mark comes from the exercise, passed as a prop). +- Add a "Score" column to the table. +- Highlight the score cell in red when `score !== null && pass_mark !== null && score < pass_mark`. + +```tsx + + {score !== null ? `${score.toFixed(1)}%` : '—'} + +``` + +The page query (`submissions/page.tsx`) joins `sessions.score` and passes `exercise.pass_mark` as a prop. + +### `app/instructor/submissions/[id]/page.tsx` + +1. **Timestamps** — replace all `new Date(x).toLocaleString()` with `formatWAT(x)`. +2. **Pasted text** — add a "Pasted Text" column to the paste events table. +3. **Flag dismissal UI** — replace the static flag reasons alert with a `FlagDismissal` client component (see below). + +#### New client component: `FlagDismissal.tsx` + +```tsx +'use client'; +// Props: submissionId, flagReasons: string[], dismissedFlags: DismissedFlag[] +// Renders each flag reason with: +// - Active: red badge + "Dismiss" button +// - Dismissed: muted/strikethrough style + "Restore" button + who dismissed + when +// On dismiss/restore: calls PUT /api/instructor/submissions/[id]/dismiss-flag +// then updates local state (optimistic) and shows toast +``` + +### `app/participant/page.tsx` + +The query gains `sessions.score` and `exercises.pass_mark`. The exercise card for completed sessions shows: + +```tsx +{sessionStatusMap.get(exercise.id) === 'completed' && ( +
+ {score !== null && ( + + {score.toFixed(1)}% + {passMark !== null && (score >= passMark ? ' ✓ Pass' : ' ✗ Fail')} + + )} + Completed +
+)} +``` + +Where `passIndicator` is `badge-green` if passing or no threshold, `badge-red` if failing. + +--- + +## `lib/flagging.ts` — Updated `evaluateFlags` + +The function signature and return type are unchanged. The paste-count query is unchanged (it counts all paste events regardless of dismissal — dismissal affects score, not re-flagging). However, `is_flagged` derivation after dismissal is handled by the dismiss-flag route, not by `evaluateFlags`. `evaluateFlags` continues to be called only on new events and always reflects the raw signal state. + +> Rationale: `evaluateFlags` is the detection layer. Dismissal is the review layer. Keeping them separate avoids re-running detection on every review action. + +--- + +## Error Handling + +| Scenario | Behaviour | +|---|---| +| `pasted_text` missing from paste event body | API accepts it as `null` (backward compat with old clients) | +| `pass_mark` is negative | API returns `400 { error: 'pass_mark must be >= 0' }` | +| `pass_mark` is non-numeric | API returns `400 { error: 'pass_mark must be a number' }` | +| Dismiss reason not in `flag_reasons` | API returns `400 { error: 'reason not found in flag_reasons' }` | +| Dismiss already-dismissed reason | API returns `400 { error: 'flag already dismissed' }` | +| Restore non-dismissed reason | API returns `400 { error: 'flag is not dismissed' }` | +| Score recalculation fails | Error is logged; API still returns success for the primary action; score remains stale | +| `totalQuestions` is 0 | `recalculateSessionScore` returns 0 without dividing | +| `formatWAT` receives invalid date string | Returns `'Invalid Date WAT'` — callers should ensure valid ISO strings | + +--- + +## Testing Strategy + +### Unit tests + +- `lib/format.ts` — `formatWAT` with known UTC inputs, verify output matches expected WAT string +- `lib/scoring.ts` — `recalculateSessionScore` with mocked `sql`, various combinations of final counts, flag counts, dismissed counts +- `lib/flagging.ts` — existing tests remain valid; add test for submission with dismissed flags to confirm `evaluateFlags` still returns raw reasons +- Dismiss-flag route — unit test auth guard (non-instructor returns 403), valid dismiss, valid restore, error cases + +### Property-based tests + +Use a property-based testing library (e.g. `fast-check` for TypeScript). + +Each property test runs a minimum of 100 iterations. + +### Integration tests + +- End-to-end paste flow: paste in editor → API stores `pasted_text` → review page shows it +- Score recalculation on threshold change: set pass_mark, verify all session scores updated +- Flag dismiss → score increases; restore → score decreases back + +--- + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: Paste text round-trip + +*For any* string pasted into the Monaco editor, the `pasted_text` value stored in `paste_events` SHALL equal the exact string that was pasted (not the full editor content, not a truncated version). + +**Validates: Requirements 1.1, 1.2** + +--- + +### Property 2: Paste event triggers flagging + +*For any* submission that receives at least one paste event, after the paste API call completes, `submissions.is_flagged` SHALL be `true` and `flag_reasons` SHALL contain at least one paste-related reason. + +**Validates: Requirements 1.5** + +--- + +### Property 3: Pass mark validation + +*For any* numeric value `v`, the exercise update API SHALL accept `v` if and only if `v >= 0`. For any `v < 0`, the API SHALL return a 400 error and leave `pass_mark` unchanged. + +**Validates: Requirements 2.2** + +--- + +### Property 4: Score formula correctness + +*For any* session with `F` final submissions out of `Q` total questions and `A` active (non-dismissed) flag reasons, the computed `Exercise_Score` SHALL equal `max(0, min(100, (F / Q) * 100 - A * FLAG_PENALTY))`. + +**Validates: Requirements 3.1, 3.2** + +--- + +### Property 5: Score is never negative + +*For any* combination of final submission count, question count, and active flag count, `Exercise_Score` SHALL always be `>= 0`. + +**Validates: Requirements 3.3** + +> Note: Property 5 is implied by Property 4 (the `max(0, ...)` clamp), but is stated separately because it is an explicit safety invariant worth testing independently with extreme inputs. + +--- + +### Property 6: Pass/fail indicator correctness + +*For any* `Exercise_Score` value `s` and pass mark `p`, the pass/fail indicator shown to the student SHALL display "Pass" if and only if `s >= p`, and "Fail" if and only if `s < p`. + +**Validates: Requirements 3.5** + +--- + +### Property 7: Score below threshold is highlighted + +*For any* score `s` and pass mark `p` where `s < p`, the score cell in the instructor submissions table SHALL be rendered with the failure highlight style. For any `s >= p`, the failure highlight SHALL NOT be applied. + +**Validates: Requirements 4.2** + +--- + +### Property 8: Dismissal is recorded with correct metadata + +*For any* flag reason `r` dismissed by instructor `i`, the resulting `dismissed_flags` array SHALL contain exactly one entry with `reason === r`, `dismissed_by === i`, and a `dismissed_at` timestamp within a reasonable window of the request time. + +**Validates: Requirements 5.2** + +--- + +### Property 9: Original flag data is preserved after dismissal + +*For any* submission, after dismissing any subset of its flag reasons, `flag_reasons` SHALL still contain all original reasons unchanged. + +**Validates: Requirements 5.3** + +--- + +### Property 10: All-dismissed clears is_flagged + +*For any* submission with `N` flag reasons, after dismissing all `N` reasons, `is_flagged` SHALL be `false`. After restoring any one of them, `is_flagged` SHALL be `true` again. + +**Validates: Requirements 5.4** + +--- + +### Property 11: Dismiss/restore is a round-trip + +*For any* submission, dismissing a flag and then restoring it SHALL return the submission to its exact pre-dismissal state (`dismissed_flags`, `is_flagged`, and `score` all restored). + +**Validates: Requirements 5.8** + +--- + +### Property 12: Non-instructor cannot dismiss flags + +*For any* request to `PUT /api/instructor/submissions/[id]/dismiss-flag` made by a user whose role is not `instructor`, the system SHALL return a 401 or 403 response and SHALL NOT modify `dismissed_flags`. + +**Validates: Requirements 5.7** + +--- + +### Property 13: WAT formatting is UTC+1 + +*For any* valid UTC timestamp `t`, `formatWAT(t)` SHALL produce a string representing the time `t + 1 hour`, and the string SHALL end with the suffix `WAT`. + +**Validates: Requirements 6.1, 6.2, 6.3, 6.4, 6.5, 6.6** + +--- + +### Property Reflection + +After reviewing all 13 properties: + +- Properties 4 and 5 overlap (5 is implied by 4), but 5 is retained as an independent invariant test with extreme inputs. +- Properties 8 and 9 are distinct: 8 tests the new `dismissed_flags` entry, 9 tests that `flag_reasons` is immutable. +- Properties 10 and 11 are distinct: 10 tests the `is_flagged` derived state, 11 tests full round-trip identity. +- No further consolidation is warranted. diff --git a/.kiro/specs/platform-improvements/requirements.md b/.kiro/specs/platform-improvements/requirements.md new file mode 100644 index 0000000..3737d9b --- /dev/null +++ b/.kiro/specs/platform-improvements/requirements.md @@ -0,0 +1,111 @@ +# Requirements Document + +## Introduction + +This spec covers four improvements to the recoding platform: + +1. **Paste detector fix** — the paste event currently saves the entire editor content instead of only the pasted text, preventing accurate integrity review. +2. **Auto-grading** — students have no visibility into their scores; instructors need to define thresholds and the platform should calculate and display scores automatically. +3. **Flag dismissal** — instructors cannot currently dismiss flags they deem legitimate, so valid student behaviour is unfairly penalised. +4. **Timezone display** — all timestamps are shown in UTC, appearing 1 hour behind for users in WAT (UTC+1). + +## Glossary + +- **Submission** — a student's final or draft response to a question in an exercise session +- **Flag** — an indicator of suspicious behaviour (paste event, excessive focus loss, etc.) +- **Exercise_Score** — the calculated total score for a student's exercise session +- **Threshold** — a numeric boundary defined by an instructor (e.g. pass mark, grade cutoff) +- **WAT** — West Africa Time, UTC+1 + +--- + +## Requirement 1: Paste Detector Captures Exact Pasted Text + +**User Story:** As an instructor, I want to see exactly what a student pasted, so that I can accurately assess integrity events. + +### Acceptance Criteria + +1. WHEN a user pastes text into the Monaco code editor THEN the system SHALL extract and save the exact pasted text content alongside the character count +2. WHEN a paste event is recorded in the database THEN the `paste_events` table SHALL store a `pasted_text` field containing only the text that was pasted, not the full editor content +3. WHEN the Monaco `onDidPaste` event fires THEN the system SHALL read the text from the pasted range using `editor.getModel()?.getValueInRange(range)` +4. WHEN a paste event occurs THEN the system SHALL CONTINUE TO display the toast warning notification to the user +5. WHEN a paste event is recorded THEN the system SHALL CONTINUE TO trigger flag evaluation and update submission flags +6. WHEN a paste event occurs without an existing `submission_id` THEN the system SHALL CONTINUE TO trigger an autosave first to obtain a `submission_id` +7. WHEN a paste event is recorded THEN the system SHALL CONTINUE TO store the `occurred_at` timestamp and `char_count` fields + +--- + +## Requirement 2: Instructor Configures Scoring Thresholds + +**User Story:** As an instructor, I want to set a pass mark and optional grade boundaries for an exercise, so that scores are calculated consistently. + +### Acceptance Criteria + +1. WHEN an instructor opens an exercise THEN the system SHALL display a threshold configuration section +2. WHEN an instructor saves a threshold THEN the system SHALL validate that the value is a non-negative number and persist it to the database +3. THE system SHALL support at minimum a single pass/fail threshold per exercise +4. WHEN an instructor updates a threshold THEN the system SHALL recalculate all existing Exercise_Score values for that exercise +5. WHEN recalculation is in progress THEN the system SHALL display a loading indicator + +--- + +## Requirement 3: Auto-Grading Calculates and Displays Student Scores + +**User Story:** As a student, I want to see my total score after completing an exercise, so that I know how I performed without waiting for manual grading. + +### Acceptance Criteria + +1. WHEN a student's session is finalised THEN the system SHALL compute an Exercise_Score based on the number of questions answered and active (non-dismissed) flags +2. WHEN a submission is flagged and the flag has not been dismissed THEN the system SHALL apply a penalty to the Exercise_Score +3. THE system SHALL ensure the Exercise_Score is never negative +4. WHEN a student views their dashboard THEN the system SHALL display the Exercise_Score for each completed exercise +5. WHEN an exercise has a configured threshold THEN the student dashboard SHALL show a pass/fail indicator alongside the score +6. WHEN an exercise has no configured threshold THEN the student dashboard SHALL display the raw score only +7. WHEN a student has not finalised an exercise THEN the system SHALL NOT display a score for that exercise +8. WHEN the Score_Calculator computes an Exercise_Score THEN the system SHALL persist it to the database associated with the session + +--- + +## Requirement 4: Instructor Views Scores in Submissions Table + +**User Story:** As an instructor, I want to see each student's score in the submissions table, so that I can quickly assess class performance. + +### Acceptance Criteria + +1. WHEN an instructor views the submissions table THEN the system SHALL display the Exercise_Score for each student +2. THE system SHALL highlight scores that fall below the configured pass threshold +3. WHEN an instructor updates a flag or threshold THEN the system SHALL recalculate and refresh the displayed scores + +--- + +## Requirement 5: Instructor Dismisses Flags + +**User Story:** As an instructor, I want to dismiss a flag on a submission when the reason was valid, so that the student is not unfairly penalised. + +### Acceptance Criteria + +1. WHEN an instructor views a flagged submission THEN the system SHALL display each flag with a dismiss action +2. WHEN an instructor dismisses a flag THEN the system SHALL record the dismissal with the instructor's ID and a timestamp +3. WHEN an instructor dismisses a flag THEN the system SHALL preserve the original flag data for audit purposes +4. WHEN all flags on a submission are dismissed THEN the system SHALL update the submission's `is_flagged` status to `false` +5. WHEN an instructor dismisses a flag THEN the system SHALL recalculate the Exercise_Score excluding the dismissed flag's penalty +6. WHEN displaying a dismissed flag THEN the system SHALL apply a distinct visual style (e.g. muted colour, strikethrough) to distinguish it from active flags +7. WHEN a non-instructor attempts to dismiss a flag THEN the system SHALL return an authorisation error and not perform the dismissal +8. WHEN an instructor wants to undo a dismissal THEN the system SHALL provide a restore action that re-activates the flag and recalculates the score + +--- + +## Requirement 6: All Timestamps Display in WAT + +**User Story:** As a platform user in Nigeria, I want all times shown in WAT (UTC+1), so that timestamps match my local clock. + +### Acceptance Criteria + +1. WHEN displaying submission timestamps THEN the system SHALL format them in WAT (UTC+1) and label them as WAT +2. WHEN displaying paste event timestamps THEN the system SHALL format them in WAT +3. WHEN displaying focus loss event timestamps THEN the system SHALL format them in WAT +4. WHEN displaying feedback submission timestamps THEN the system SHALL format them in WAT +5. WHEN displaying user creation dates THEN the system SHALL format them in WAT +6. WHEN displaying live monitor heartbeat timestamps THEN the system SHALL format them in WAT +7. WHEN storing timestamps in the database THEN the system SHALL CONTINUE TO store them in UTC +8. WHEN performing date calculations or duration comparisons THEN the system SHALL CONTINUE TO use UTC internally diff --git a/.kiro/specs/platform-improvements/tasks.md b/.kiro/specs/platform-improvements/tasks.md new file mode 100644 index 0000000..fb32a47 --- /dev/null +++ b/.kiro/specs/platform-improvements/tasks.md @@ -0,0 +1,197 @@ +# Implementation Plan: Platform Improvements + +## Overview + +Incremental implementation of four improvements: paste text capture, auto-grading, flag dismissal, and WAT timezone display. Each task builds on the previous, ending with full integration. + +## Tasks + +- [x] 1. Database migration and shared utilities + - [x] 1.1 Write migration `migrations/0008_platform_improvements.sql` + - Add `pasted_text TEXT` to `paste_events` + - Add `pass_mark NUMERIC` to `exercises` + - Add `score NUMERIC` to `sessions` + - Add `dismissed_flags JSONB NOT NULL DEFAULT '[]'` to `submissions` + - _Requirements: 1.2, 2.2, 3.8, 5.2_ + + - [x] 1.2 Create `lib/format.ts` with `formatWAT` and `formatDateWAT` + - Implement as specified in design using `Africa/Lagos` timezone + - Handle invalid date strings gracefully + - _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6_ + + - [ ]* 1.3 Write property test for `formatWAT` (Property 13) + - **Property 13: WAT formatting is UTC+1** + - For any valid UTC timestamp, output must represent time + 1 hour and end with `WAT` + - **Validates: Requirements 6.1, 6.2, 6.3, 6.4, 6.5, 6.6** + + - [x] 1.4 Create `lib/scoring.ts` with `recalculateSessionScore` + - Implement formula: `max(0, min(100, (F/Q)*100 - A*FLAG_PENALTY))` + - Read `FLAG_PENALTY` from `process.env.FLAG_PENALTY` defaulting to `10` + - Handle `totalQuestions === 0` edge case (return 0) + - Persist score to `sessions.score` + - _Requirements: 3.1, 3.2, 3.3, 3.8_ + + - [ ]* 1.5 Write property test for `recalculateSessionScore` formula (Property 4) + - **Property 4: Score formula correctness** + - Mock `sql` and verify `max(0, min(100, (F/Q)*100 - A*FLAG_PENALTY))` for arbitrary F, Q, A + - **Validates: Requirements 3.1, 3.2** + + - [ ]* 1.6 Write property test for score non-negativity (Property 5) + - **Property 5: Score is never negative** + - Use extreme inputs (large flag counts, zero submissions) to confirm score >= 0 + - **Validates: Requirements 3.3** + +- [ ] 2. Checkpoint — Ensure migration and utility tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 3. Paste detector fix + - [x] 3.1 Modify `app/participant/session/[id]/CodeEditor.tsx` + - In `onDidPaste` handler, extract `pastedText` via `model?.getValueInRange(e.range)` + - Derive `charCount` from `pastedText.length` + - Include `pasted_text` field in the paste API request body + - _Requirements: 1.1, 1.3, 1.4, 1.6, 1.7_ + + - [x] 3.2 Modify `app/api/events/paste/route.ts` + - Accept optional `pasted_text` in request body (null for backward compat) + - Insert `pasted_text` into `paste_events` + - _Requirements: 1.1, 1.2_ + + - [ ]* 3.3 Write property test for paste text round-trip (Property 1) + - **Property 1: Paste text round-trip** + - For any string, the value stored in `paste_events.pasted_text` must equal the original pasted string exactly + - **Validates: Requirements 1.1, 1.2** + +- [x] 4. Auto-grading — scoring API and session finalisation + - [x] 4.1 Modify `app/api/submissions/[sessionId]/final/route.ts` + - After marking submission final, check if all questions for the session are now final + - If so, set `sessions.closed_at = now()` and call `recalculateSessionScore(sessionId)` + - _Requirements: 3.1, 3.8_ + + - [x] 4.2 Modify `app/api/instructor/exercises/[id]/route.ts` (PUT handler) + - Accept `pass_mark?: number | null` in request body + - Validate `pass_mark >= 0`; return `400` if negative or non-numeric + - Persist `pass_mark` to `exercises` + - Call `recalculateSessionScore` for every session belonging to this exercise + - _Requirements: 2.2, 2.4_ + + - [ ]* 4.3 Write property test for pass mark validation (Property 3) + - **Property 3: Pass mark validation** + - For any `v >= 0`, API must accept; for any `v < 0`, API must return 400 and leave `pass_mark` unchanged + - **Validates: Requirements 2.2** + +- [-] 5. Flag dismissal API + - [x] 5.1 Create `app/api/instructor/submissions/[id]/dismiss-flag/route.ts` + - Auth guard: return 403 for non-instructors + - Accept `{ reason: string, restore?: boolean }` in body + - Validate `reason` exists in `flag_reasons`; return 400 otherwise + - Dismiss: append `{ reason, dismissed_by, dismissed_at }` to `dismissed_flags` if not already present + - Restore: remove matching entry from `dismissed_flags` + - Recompute `is_flagged` based on remaining active reasons + - Call `recalculateSessionScore(sessionId)` + - Return `{ submission_id, dismissed_flags, is_flagged, score }` + - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.7, 5.8_ + + - [ ]* 5.2 Write property test for dismissal metadata (Property 8) + - **Property 8: Dismissal is recorded with correct metadata** + - For any reason `r` dismissed by instructor `i`, `dismissed_flags` must contain exactly one entry with matching `reason`, `dismissed_by`, and a recent `dismissed_at` + - **Validates: Requirements 5.2** + + - [ ]* 5.3 Write property test for flag data immutability (Property 9) + - **Property 9: Original flag data is preserved after dismissal** + - After dismissing any subset of reasons, `flag_reasons` must remain unchanged + - **Validates: Requirements 5.3** + + - [ ]* 5.4 Write property test for all-dismissed clears is_flagged (Property 10) + - **Property 10: All-dismissed clears is_flagged** + - After dismissing all N reasons, `is_flagged` must be false; after restoring any one, `is_flagged` must be true + - **Validates: Requirements 5.4** + + - [ ]* 5.5 Write property test for dismiss/restore round-trip (Property 11) + - **Property 11: Dismiss/restore is a round-trip** + - Dismissing then restoring a flag must return `dismissed_flags`, `is_flagged`, and `score` to their exact pre-dismissal state + - **Validates: Requirements 5.8** + + - [ ]* 5.6 Write property test for non-instructor auth guard (Property 12) + - **Property 12: Non-instructor cannot dismiss flags** + - Any request from a non-instructor role must return 401 or 403 and must not modify `dismissed_flags` + - **Validates: Requirements 5.7** + +- [ ] 6. Checkpoint — Ensure all API and scoring tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 7. Instructor UI — exercise manager and submissions table + - [x] 7.1 Modify `app/instructor/exercises/[id]/ExerciseManager.tsx` + - Add `pass_mark: number | null` to the `Exercise` interface + - Add `passMark` state and a "Scoring" card with a number input (0–100) + - Wire "Save Pass Mark" button to call `patch({ pass_mark: ... })` + - Show loading indicator while saving + - _Requirements: 2.1, 2.2, 2.3, 2.5_ + + - [x] 7.2 Modify `app/instructor/exercises/[id]/submissions/page.tsx` + - Join `sessions.score` in the query + - Pass `exercise.pass_mark` as a prop to `SubmissionsTable` + - _Requirements: 4.1, 4.2_ + + - [x] 7.3 Modify `app/instructor/exercises/[id]/submissions/SubmissionsTable.tsx` + - Add `score: number | null` to `SubmissionRow` and `pass_mark: number | null` prop + - Add "Score" column displaying `score.toFixed(1)%` or `—` + - Highlight score cell in `var(--red)` when `score < pass_mark` + - _Requirements: 4.1, 4.2_ + + - [ ]* 7.4 Write property test for score highlight logic (Property 7) + - **Property 7: Score below threshold is highlighted** + - For any `s < p`, failure style must be applied; for any `s >= p`, it must not + - **Validates: Requirements 4.2** + +- [x] 8. Instructor UI — submission review page + - [x] 8.1 Create `app/instructor/submissions/[id]/FlagDismissal.tsx` client component + - Accept `submissionId`, `flagReasons`, `dismissedFlags` as props + - Render each reason with active (red badge + Dismiss button) or dismissed (muted/strikethrough + Restore button + who/when) style + - On dismiss/restore, call `PUT /api/instructor/submissions/[id]/dismiss-flag` and update local state + - _Requirements: 5.1, 5.2, 5.6, 5.8_ + + - [x] 8.2 Modify `app/instructor/submissions/[id]/page.tsx` + - Replace static flag reasons alert with `` component + - Add "Pasted Text" column to the paste events table + - Replace all `new Date(x).toLocaleString()` calls with `formatWAT(x)` + - _Requirements: 1.2, 5.1, 5.6, 6.1, 6.2, 6.3_ + +- [x] 9. WAT timestamps across remaining instructor pages + - [x] 9.1 Modify `app/instructor/exercises/[id]/submissions/SubmissionsTable.tsx` + - Replace `submitted_at` formatting with `formatWAT` + - _Requirements: 6.1_ + + - [x] 9.2 Modify `app/instructor/feedback/page.tsx` + - Replace feedback `submitted_at` formatting with `formatWAT` + - _Requirements: 6.4_ + + - [x] 9.3 Modify `app/instructor/users/UserManager.tsx` + - Replace `created_at` formatting with `formatWAT` or `formatDateWAT` + - _Requirements: 6.5_ + + - [x] 9.4 Modify `app/instructor/LiveMonitor.tsx` + - Replace heartbeat timestamp formatting with `formatWAT` + - _Requirements: 6.6_ + +- [x] 10. Participant dashboard — score display + - [x] 10.1 Modify `app/participant/page.tsx` + - Join `sessions.score` and `exercises.pass_mark` in the query + - For completed sessions, render score badge with pass/fail indicator when threshold is set + - Use `badge-green` when passing or no threshold, `badge-red` when failing + - _Requirements: 3.4, 3.5, 3.6, 3.7_ + + - [ ]* 10.2 Write property test for pass/fail indicator (Property 6) + - **Property 6: Pass/fail indicator correctness** + - For any score `s` and pass mark `p`, "Pass" shown iff `s >= p`, "Fail" shown iff `s < p` + - **Validates: Requirements 3.5_ + +- [ ] 11. Final checkpoint — Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for a faster MVP +- Each task references specific requirements for traceability +- Property tests use `fast-check`; run with `npx vitest --run` +- `FLAG_PENALTY` defaults to `10` and is configurable via environment variable +- The `dismissed_flags` overlay never modifies `flag_reasons` — original audit data is always preserved diff --git a/app/api/events/paste/route.ts b/app/api/events/paste/route.ts index 1ddc833..bb35763 100644 --- a/app/api/events/paste/route.ts +++ b/app/api/events/paste/route.ts @@ -14,9 +14,10 @@ export async function POST(req: NextRequest) { const userId = session.user.id; const body = await req.json(); - const { submission_id, char_count, occurred_at } = body as { + const { submission_id, char_count, pasted_text, occurred_at } = body as { submission_id: string; char_count: number; + pasted_text?: string | null; occurred_at: string; }; @@ -40,8 +41,8 @@ export async function POST(req: NextRequest) { // Insert paste event const inserted = await sql` - INSERT INTO paste_events (submission_id, char_count, occurred_at) - VALUES (${submission_id}, ${char_count}, ${occurred_at}) + INSERT INTO paste_events (submission_id, char_count, pasted_text, occurred_at) + VALUES (${submission_id}, ${char_count}, ${pasted_text ?? null}, ${occurred_at}) RETURNING id `; diff --git a/app/api/instructor/exercises/[id]/recalculate-scores/route.ts b/app/api/instructor/exercises/[id]/recalculate-scores/route.ts new file mode 100644 index 0000000..fedfb20 --- /dev/null +++ b/app/api/instructor/exercises/[id]/recalculate-scores/route.ts @@ -0,0 +1,209 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { sql } from '@/lib/db'; + +const FLAG_PENALTY = parseFloat(process.env.FLAG_PENALTY ?? '10'); + +export async function POST( + _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 exerciseId = params.id; + + const exRows = await sql` + SELECT question_count, pass_mark, min_questions_required, flag_fails, + max_paste_chars, max_focus_loss, min_edit_events, min_response_length + FROM exercises WHERE id = ${exerciseId} + `; + if (exRows.length === 0) return NextResponse.json({ error: 'Exercise not found' }, { status: 404 }); + + const ex = exRows[0] as { + question_count: number; + pass_mark: number | null; + min_questions_required: number | null; + flag_fails: boolean; + max_paste_chars: number | null; + max_focus_loss: number | null; + min_edit_events: number | null; + min_response_length: number | null; + }; + + const DEFAULT_FOCUS_THRESHOLD = parseInt(process.env.FLAG_FOCUS_LOSS_THRESHOLD ?? '3', 10); + const DEFAULT_MIN_EDIT = parseInt(process.env.FLAG_MIN_EDIT_EVENTS ?? '10', 10); + const DEFAULT_MIN_LENGTH = parseInt(process.env.FLAG_MIN_RESPONSE_LENGTH ?? '200', 10); + const focusThreshold = ex.max_focus_loss ?? DEFAULT_FOCUS_THRESHOLD; + const minEditEvents = ex.min_edit_events ?? DEFAULT_MIN_EDIT; + 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 + await sql` + UPDATE submissions SET is_final = true + WHERE session_id IN (SELECT id FROM sessions WHERE exercise_id = ${exerciseId}) + AND is_final = false + `; + + // 2. Close open sessions + await sql` + UPDATE sessions SET closed_at = now() + WHERE exercise_id = ${exerciseId} AND closed_at IS NULL AND started_at IS NOT NULL + `; + + // 3. Re-evaluate paste flags with threshold + if (ex.max_paste_chars !== null) { + const threshold = ex.max_paste_chars; + await sql` + UPDATE submissions sub + SET + flag_reasons = ( + ARRAY( + SELECT r FROM UNNEST(COALESCE(sub.flag_reasons, ARRAY[]::text[])) AS r + WHERE r NOT LIKE 'paste_detected:%' + ) + || CASE WHEN EXISTS ( + SELECT 1 FROM paste_events pe + WHERE pe.submission_id = sub.id AND pe.char_count > ${threshold} + ) THEN + ARRAY['paste_detected: exceeds ' || ${threshold}::text || ' char threshold'] + ELSE ARRAY[]::text[] END + ), + is_flagged = ( + EXISTS ( + SELECT 1 FROM UNNEST(COALESCE(sub.flag_reasons, ARRAY[]::text[])) AS r + WHERE r NOT LIKE 'paste_detected:%' + ) + OR EXISTS ( + SELECT 1 FROM paste_events pe + WHERE pe.submission_id = sub.id AND pe.char_count > ${threshold} + ) + ) + WHERE sub.session_id IN (SELECT id FROM sessions WHERE exercise_id = ${exerciseId}) + `; + } + + // 4. Re-evaluate focus loss flags with per-exercise threshold + await sql` + UPDATE submissions sub + SET + flag_reasons = ( + ARRAY( + SELECT r FROM UNNEST(COALESCE(sub.flag_reasons, ARRAY[]::text[])) AS r + WHERE r NOT LIKE 'focus_loss_exceeded:%' + ) + || CASE WHEN ( + SELECT COUNT(*)::int FROM focus_events fe WHERE fe.session_id = sub.session_id + ) > ${focusThreshold} THEN + ARRAY['focus_loss_exceeded: ' || ( + SELECT COUNT(*)::text FROM focus_events fe WHERE fe.session_id = sub.session_id + ) || ' focus-loss event(s) (threshold: ' || ${focusThreshold}::text || ')'] + ELSE ARRAY[]::text[] END + ), + is_flagged = ( + EXISTS ( + SELECT 1 FROM UNNEST(COALESCE(sub.flag_reasons, ARRAY[]::text[])) AS r + WHERE r NOT LIKE 'focus_loss_exceeded:%' + ) + OR ( + SELECT COUNT(*)::int FROM focus_events fe WHERE fe.session_id = sub.session_id + ) > ${focusThreshold} + ) + WHERE sub.session_id IN (SELECT id FROM sessions WHERE exercise_id = ${exerciseId}) + `; + + // 5. Re-evaluate low_edit_count flags with per-exercise thresholds + await sql` + UPDATE submissions sub + SET + flag_reasons = ( + ARRAY( + SELECT r FROM UNNEST(COALESCE(sub.flag_reasons, ARRAY[]::text[])) AS r + WHERE r NOT LIKE 'low_edit_count:%' + ) + || CASE + WHEN ( + SELECT COUNT(*)::int FROM edit_events ee WHERE ee.submission_id = sub.id + ) < ${minEditEvents} + AND LENGTH(COALESCE(sub.response_text, '')) >= ${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'] + ELSE ARRAY[]::text[] + END + ), + is_flagged = ( + EXISTS ( + SELECT 1 FROM UNNEST(COALESCE(sub.flag_reasons, ARRAY[]::text[])) AS r + WHERE r NOT LIKE 'low_edit_count:%' + ) + OR ( + (SELECT COUNT(*)::int FROM edit_events ee WHERE ee.submission_id = sub.id) < ${minEditEvents} + AND LENGTH(COALESCE(sub.response_text, '')) >= ${minResponseLength} + ) + ) + WHERE sub.session_id IN (SELECT id FROM sessions WHERE exercise_id = ${exerciseId}) + `; + + // 6. Get per-session aggregates + const sessionData = await sql` + SELECT + s.id AS session_id, + SUM(CASE WHEN sub.is_final THEN 1 ELSE 0 END)::int AS final_count, + COALESCE(SUM( + GREATEST(0, + COALESCE(array_length(sub.flag_reasons, 1), 0) + - jsonb_array_length(sub.dismissed_flags) + ) + ), 0)::int AS active_flag_reasons, + BOOL_OR( + sub.is_flagged = true + AND jsonb_array_length(sub.dismissed_flags) < COALESCE(array_length(sub.flag_reasons, 1), 0) + ) AS has_active_flag, + COALESCE(( + SELECT SUM(pe.char_count) + FROM paste_events pe + JOIN submissions sub2 ON sub2.id = pe.submission_id + WHERE sub2.session_id = s.id + ), 0)::int AS total_paste_chars, + (SELECT COUNT(*)::int FROM focus_events fe WHERE fe.session_id = s.id) AS focus_loss_count + FROM sessions s + LEFT JOIN submissions sub ON sub.session_id = s.id + WHERE s.exercise_id = ${exerciseId} + GROUP BY s.id + `; + + // 5. Compute score + passed in JS, then bulk update + for (const row of sessionData) { + const sessionId = row.session_id as string; + const finalCount = (row.final_count as number) ?? 0; + const activeFlagReasons = (row.active_flag_reasons as number) ?? 0; + const hasActiveFlag = (row.has_active_flag as boolean) ?? false; + const totalPasteChars = (row.total_paste_chars as number) ?? 0; + const focusLossCount = (row.focus_loss_count as number) ?? 0; + + const raw = (finalCount / ex.question_count) * 100 - activeFlagReasons * FLAG_PENALTY; + const score = Math.min(100, Math.max(0, raw)); + + let passed: boolean | null = null; + if (hasConstraints) { + passed = true; + if (ex.pass_mark !== null && score < ex.pass_mark) passed = false; + if (ex.min_questions_required !== null && finalCount < ex.min_questions_required) passed = false; + if (ex.flag_fails && hasActiveFlag) passed = false; + if (ex.max_paste_chars !== null && totalPasteChars > ex.max_paste_chars) passed = false; + if (ex.max_focus_loss !== null && focusLossCount > ex.max_focus_loss) passed = false; + } + + await sql` + UPDATE sessions SET score = ${score}, passed = ${passed} + WHERE id = ${sessionId} + `; + } + + return NextResponse.json({ recalculated: sessionData.length }); +} diff --git a/app/api/instructor/exercises/[id]/route.ts b/app/api/instructor/exercises/[id]/route.ts index 87cbcd5..f4fc8dd 100644 --- a/app/api/instructor/exercises/[id]/route.ts +++ b/app/api/instructor/exercises/[id]/route.ts @@ -3,6 +3,7 @@ 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, @@ -21,6 +22,13 @@ export async function PUT( start_time?: string | null; end_time?: string | null; duration_limit?: string | null; + pass_mark?: number | null; + min_questions_required?: number | null; + flag_fails?: boolean; + max_paste_chars?: number | null; + max_focus_loss?: number | null; + min_edit_events?: number | null; + min_response_length?: number | null; }; try { @@ -90,9 +98,51 @@ export async function PUT( } } + // Update pass mark and scoring constraints if provided + if ('pass_mark' in body || 'min_questions_required' in body || 'flag_fails' in body || 'max_paste_chars' in body) { + if (body.pass_mark !== null && body.pass_mark !== undefined) { + if (typeof body.pass_mark !== 'number' || isNaN(body.pass_mark)) { + return NextResponse.json({ error: 'pass_mark must be a number' }, { status: 400 }); + } + if (body.pass_mark < 0) { + return NextResponse.json({ error: 'pass_mark must be >= 0' }, { status: 400 }); + } + } + if ('pass_mark' in body) { + await sql`UPDATE exercises SET pass_mark = ${body.pass_mark ?? null} WHERE id = ${exerciseId}`; + } + if ('min_questions_required' in body) { + await sql`UPDATE exercises SET min_questions_required = ${body.min_questions_required ?? null} WHERE id = ${exerciseId}`; + } + if ('flag_fails' in body) { + await sql`UPDATE exercises SET flag_fails = ${body.flag_fails ?? false} WHERE id = ${exerciseId}`; + } + if ('max_paste_chars' in body) { + await sql`UPDATE exercises SET max_paste_chars = ${body.max_paste_chars ?? null} WHERE id = ${exerciseId}`; + } + if ('max_focus_loss' in body) { + await sql`UPDATE exercises SET max_focus_loss = ${body.max_focus_loss ?? null} WHERE id = ${exerciseId}`; + } + if ('min_edit_events' in body) { + await sql`UPDATE exercises SET min_edit_events = ${body.min_edit_events ?? null} WHERE id = ${exerciseId}`; + } + if ('min_response_length' in body) { + await sql`UPDATE exercises SET min_response_length = ${body.min_response_length ?? null} WHERE id = ${exerciseId}`; + } + await audit(session.user.id, 'exercise.scoring_updated', 'exercise', exerciseId, { + pass_mark: body.pass_mark, min_questions_required: body.min_questions_required, + flag_fails: body.flag_fails, max_paste_chars: body.max_paste_chars, + }); + // Recalculate scores for all sessions of this exercise + const sessionRows = await sql`SELECT id FROM sessions WHERE exercise_id = ${exerciseId}`; + await Promise.all(sessionRows.map((s) => recalculateSessionScore(s.id as string).catch(() => {}))); + } + // Return updated exercise with assignments const rows = await sql` - SELECT e.id, e.slug, e.title, e.enabled, e.question_count, + SELECT e.id, e.slug, e.title, e.enabled, e.question_count, e.pass_mark, + e.min_questions_required, e.flag_fails, e.max_paste_chars, e.max_focus_loss, + e.min_edit_events, e.min_response_length, COALESCE( json_agg(ea.user_id) FILTER (WHERE ea.user_id IS NOT NULL), '[]' @@ -100,7 +150,9 @@ export async function PUT( FROM exercises e LEFT JOIN exercise_assignments ea ON ea.exercise_id = e.id WHERE e.id = ${exerciseId} - GROUP BY e.id, e.slug, e.title, e.enabled, e.question_count + GROUP BY e.id, e.slug, e.title, e.enabled, e.question_count, e.pass_mark, + e.min_questions_required, e.flag_fails, e.max_paste_chars, e.max_focus_loss, + e.min_edit_events, e.min_response_length `; if (rows.length === 0) { diff --git a/app/api/instructor/submissions/[id]/dismiss-flag/route.ts b/app/api/instructor/submissions/[id]/dismiss-flag/route.ts new file mode 100644 index 0000000..95a8c0e --- /dev/null +++ b/app/api/instructor/submissions/[id]/dismiss-flag/route.ts @@ -0,0 +1,118 @@ +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'; + +interface DismissedFlag { + reason: string; + dismissed_by: string; + dismissed_at: string; +} + +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; + let body: { reason: string; restore?: boolean }; + + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + if (!body.reason || typeof body.reason !== 'string') { + return NextResponse.json({ error: 'reason is required' }, { status: 400 }); + } + + // Load submission + const rows = await sql` + SELECT sub.id, sub.session_id, sub.flag_reasons, sub.dismissed_flags + FROM submissions sub + WHERE sub.id = ${submissionId} + LIMIT 1 + `; + + if (rows.length === 0) { + return NextResponse.json({ error: 'Submission not found' }, { status: 404 }); + } + + const sub = rows[0] as { + id: string; + session_id: string; + flag_reasons: string[] | null; + dismissed_flags: DismissedFlag[]; + }; + + const flagReasons: string[] = sub.flag_reasons ?? []; + const dismissedFlags: DismissedFlag[] = sub.dismissed_flags ?? []; + + // Validate reason exists in flag_reasons + if (!flagReasons.includes(body.reason)) { + return NextResponse.json({ error: 'reason not found in flag_reasons' }, { status: 400 }); + } + + let updatedDismissed: DismissedFlag[]; + + if (body.restore) { + // Restore: remove the dismissal entry + const existing = dismissedFlags.find((d) => d.reason === body.reason); + if (!existing) { + return NextResponse.json({ error: 'flag is not dismissed' }, { status: 400 }); + } + updatedDismissed = dismissedFlags.filter((d) => d.reason !== body.reason); + } else { + // Dismiss: add entry if not already dismissed + const alreadyDismissed = dismissedFlags.some((d) => d.reason === body.reason); + if (alreadyDismissed) { + return NextResponse.json({ error: 'flag already dismissed' }, { status: 400 }); + } + updatedDismissed = [ + ...dismissedFlags, + { + reason: body.reason, + dismissed_by: session.user.id, + dismissed_at: new Date().toISOString(), + }, + ]; + } + + // Recompute is_flagged: any flag_reason not in dismissed_flags → still flagged + const dismissedReasons = new Set(updatedDismissed.map((d) => d.reason)); + const isFlagged = flagReasons.some((r) => !dismissedReasons.has(r)); + + // Persist + await sql` + UPDATE submissions + SET dismissed_flags = ${JSON.stringify(updatedDismissed)}::jsonb, + is_flagged = ${isFlagged} + WHERE id = ${submissionId} + `; + + await audit( + session.user.id, + body.restore ? 'submission.flag_restored' : 'submission.flag_dismissed', + 'submission', + submissionId, + { reason: body.reason } + ); + + // Recalculate score + const score = await recalculateSessionScore(sub.session_id).catch(() => null); + + return NextResponse.json({ + submission_id: submissionId, + dismissed_flags: updatedDismissed, + is_flagged: isFlagged, + score, + }); +} diff --git a/app/api/submissions/[sessionId]/final/route.ts b/app/api/submissions/[sessionId]/final/route.ts index 6616ff3..1c0d779 100644 --- a/app/api/submissions/[sessionId]/final/route.ts +++ b/app/api/submissions/[sessionId]/final/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { sql } from '@/lib/db'; +import { recalculateSessionScore } from '@/lib/scoring'; export async function POST( req: NextRequest, @@ -68,5 +69,30 @@ export async function POST( WHERE id = ${submission.id} `; + // Check if all questions for this session are now final; if so, close and score + const exerciseRows = await sql` + SELECT e.question_count + FROM sessions s + JOIN exercises e ON e.id = s.exercise_id + WHERE s.id = ${sessionId} + LIMIT 1 + `; + const totalQuestions = (exerciseRows[0]?.question_count as number) ?? 0; + + const finalCountRows = await sql` + SELECT COUNT(*)::int AS count + FROM submissions + WHERE session_id = ${sessionId} AND is_final = true + `; + const finalCount = (finalCountRows[0]?.count as number) ?? 0; + + if (totalQuestions > 0 && finalCount >= totalQuestions) { + await sql` + UPDATE sessions SET closed_at = now() + WHERE id = ${sessionId} AND closed_at IS NULL + `; + await recalculateSessionScore(sessionId).catch(() => {}); + } + return NextResponse.json({ submission_id: submission.id }, { status: 200 }); } diff --git a/app/instructor/AnalyticsPanel.tsx b/app/instructor/AnalyticsPanel.tsx new file mode 100644 index 0000000..f16134d --- /dev/null +++ b/app/instructor/AnalyticsPanel.tsx @@ -0,0 +1,221 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { BarChart2, Table2, CheckCircle, XCircle, AlertTriangle } from 'lucide-react'; +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, + ResponsiveContainer, +} from 'recharts'; + +interface ExerciseAnalytics { + exercise_id: string; + title: string; + total_sessions: number; + completed_sessions: number; + passed_sessions: number; + failed_sessions: number; + flagged_sessions: number; + avg_score: number | null; + avg_questions_answered: number | null; +} + +interface Props { + analytics: ExerciseAnalytics[]; + totalSessions: number; + totalCompleted: number; + totalPassed: number; + totalFailed: number; + totalFlagged: number; +} + +const COLORS = { + passed: '#22c55e', + failed: '#ef4444', + flagged: '#f97316', + score: '#6366f1', + completed: '#64748b', +}; + +export default function AnalyticsPanel({ + analytics, totalSessions, totalCompleted, totalPassed, totalFailed, totalFlagged, +}: Props) { + const [view, setView] = useState<'table' | 'chart'>('table'); + + const filtered = analytics.filter((a) => a.total_sessions > 0); + + // Shorten titles for chart labels + const chartData = filtered.map((a) => ({ + name: a.title.length > 12 ? a.title.slice(0, 12) + '…' : a.title, + fullTitle: a.title, + exercise_id: a.exercise_id, + Passed: a.passed_sessions, + Failed: a.failed_sessions, + Flagged: a.flagged_sessions, + 'Avg Score': a.avg_score !== null ? Number(a.avg_score) : 0, + Completed: a.completed_sessions, + })); + + const passRate = (totalPassed + totalFailed) > 0 + ? Math.round((totalPassed / (totalPassed + totalFailed)) * 100) + : null; + + return ( +
+
+ + Cohort Analytics + +
+ + +
+
+ + {/* Cohort summary tiles */} +
+ {[ + { label: 'Sessions', value: totalSessions, color: 'var(--text)' }, + { label: 'Completed', value: totalCompleted, color: 'var(--text2)' }, + { label: 'Passed', value: totalPassed, color: COLORS.passed }, + { label: 'Failed', value: totalFailed, color: COLORS.failed }, + { label: 'Flagged', value: totalFlagged, color: COLORS.flagged }, + ...(passRate !== null ? [{ label: 'Pass Rate', value: `${passRate}%`, color: passRate >= 50 ? COLORS.passed : COLORS.failed }] : []), + ].map(({ label, value, color }) => ( +
+
{value}
+
{label}
+
+ ))} +
+ + {view === 'table' ? ( +
+ + + + + + + + + + + + + + {filtered.map((a) => { + const pr = (a.passed_sessions + a.failed_sessions) > 0 + ? Math.round((a.passed_sessions / (a.passed_sessions + a.failed_sessions)) * 100) + : null; + const cr = Math.round((a.completed_sessions / a.total_sessions) * 100); + return ( + + + + + + + + + + ); + })} + +
ExerciseSessionsCompletedAvg ScoreAvg QsPass / FailFlagged
+ + {a.title} + + {a.total_sessions} + 50 ? 'var(--text2)' : COLORS.flagged, fontWeight: 600 }}> + {a.completed_sessions} + + ({cr}%) + = 50 ? COLORS.passed : COLORS.failed) : 'var(--text3)' }}> + {a.avg_score !== null ? `${Number(a.avg_score).toFixed(1)}%` : '—'} + + {a.avg_questions_answered !== null ? Number(a.avg_questions_answered).toFixed(1) : '—'} + + {pr !== null ? ( + + + {a.passed_sessions} + + / + + {a.failed_sessions} + + = 50 ? 'badge-green' : 'badge-red'}`} style={{ fontSize: 10 }}> + {pr}% + + + ) : ( + No constraints + )} + + {a.flagged_sessions > 0 ? ( + + {a.flagged_sessions} + + ({Math.round((a.flagged_sessions / a.total_sessions) * 100)}%) + + + ) : ( + + )} +
+
+ ) : ( +
+ {/* Pass / Fail / Flagged bar chart */} +
+
Pass / Fail / Flagged per Exercise
+ + + + + + payload?.[0]?.payload?.fullTitle ?? ''} + /> + + + + + + +
+ + {/* Avg Score bar chart */} +
+
Average Score per Exercise (%)
+ + + + + + payload?.[0]?.payload?.fullTitle ?? ''} + formatter={(v: number) => [`${v.toFixed(1)}%`, 'Avg Score']} + /> + + + +
+
+ )} +
+ ); +} diff --git a/app/instructor/LiveMonitor.tsx b/app/instructor/LiveMonitor.tsx index 377f51e..fe1f658 100644 --- a/app/instructor/LiveMonitor.tsx +++ b/app/instructor/LiveMonitor.tsx @@ -46,8 +46,10 @@ type LiveEvent = PasteEvent | FocusEvent | KeystrokeBatchEvent | ErrorEvent; const MAX_EVENTS = 100; +import { formatWAT } from '@/lib/format'; + function formatTime(iso: string): string { - try { return new Date(iso).toLocaleTimeString(); } catch { return iso; } + try { return formatWAT(iso, { year: undefined, month: undefined, day: undefined, second: '2-digit' }); } catch { return iso; } } function getUsername(event: LiveEvent): string { @@ -94,7 +96,7 @@ export default function LiveMonitor() { let parsed: LiveEvent | HeartbeatEvent; try { parsed = JSON.parse(e.data); } catch { return; } if (parsed.type === 'heartbeat') { - setLastHeartbeat(new Date().toLocaleTimeString()); + setLastHeartbeat(formatWAT(new Date(), { year: undefined, month: undefined, day: undefined, second: '2-digit' })); return; } setEvents((prev) => { diff --git a/app/instructor/exercises/[id]/ExerciseManager.tsx b/app/instructor/exercises/[id]/ExerciseManager.tsx index 184eb9a..b4ba7d7 100644 --- a/app/instructor/exercises/[id]/ExerciseManager.tsx +++ b/app/instructor/exercises/[id]/ExerciseManager.tsx @@ -9,6 +9,13 @@ interface Exercise { id: string; slug: string; title: string; enabled: boolean; question_count: number; assigned_user_ids: string[]; start_time: string | null; end_time: string | null; duration_limit: string | null; + pass_mark: number | null; + min_questions_required: number | null; + flag_fails: boolean; + max_paste_chars: number | null; + max_focus_loss: number | null; + min_edit_events: number | null; + min_response_length: number | null; } interface Session { id: string; start_time: string | null; end_time: string | null; @@ -25,6 +32,7 @@ export default function ExerciseManager({ exercise: initial, sessions, assignedU const [exercise, setExercise] = useState(initial); const [assignedUsers, setAssignedUsers] = useState(initialAssigned); const [saving, setSaving] = useState(false); + const [recalculating, setRecalculating] = useState(false); const unstartedSession = sessions.find((s) => !s.started_at); const [startTime, setStartTime] = useState( @@ -36,6 +44,25 @@ export default function ExerciseManager({ exercise: initial, sessions, assignedU const [durationLimit, setDurationLimit] = useState( initial.duration_limit ?? unstartedSession?.duration_limit ?? '' ); + const [passMark, setPassMark] = useState( + initial.pass_mark != null ? String(initial.pass_mark) : '' + ); + const [minQuestions, setMinQuestions] = useState( + initial.min_questions_required != null ? String(initial.min_questions_required) : '' + ); + const [flagFails, setFlagFails] = useState(initial.flag_fails ?? false); + const [maxPasteChars, setMaxPasteChars] = useState( + initial.max_paste_chars != null ? String(initial.max_paste_chars) : '' + ); + const [maxFocusLoss, setMaxFocusLoss] = useState( + initial.max_focus_loss != null ? String(initial.max_focus_loss) : '' + ); + const [minEditEvents, setMinEditEvents] = useState( + initial.min_edit_events != null ? String(initial.min_edit_events) : '' + ); + const [minResponseLength, setMinResponseLength] = useState( + initial.min_response_length != null ? String(initial.min_response_length) : '' + ); async function patch(body: Record) { setSaving(true); @@ -137,6 +164,142 @@ export default function ExerciseManager({ exercise: initial, sessions, assignedU + + + {/* Scoring */} +
+
Scoring & Pass Constraints
+

+ A participant passes only if all configured constraints are met. Leave a field blank to disable that constraint. +

+
+
+ + setPassMark(e.target.value)} + /> + Score must be ≥ this value +
+
+ + setMinQuestions(e.target.value)} + /> + Must answer at least this many +
+
+ + setMaxPasteChars(e.target.value)} + /> + Total pasted chars must be ≤ this +
+
+ + setMaxFocusLoss(e.target.value)} + /> + Overrides env default ({process.env.FLAG_FOCUS_LOSS_THRESHOLD ?? 3}) +
+
+ + setMinEditEvents(e.target.value)} + /> + Flag if edits below this (default {process.env.FLAG_MIN_EDIT_EVENTS ?? 10}) +
+
+ + setMinResponseLength(e.target.value)} + /> + Only check edit count if response ≥ this length +
+
+
+ +
+ + +
+ + {/* Actions */} +
+
Actions
+
+ + View Submissions + + + Export CSV + +
+
+ {/* Participants */}
@@ -182,19 +345,6 @@ export default function ExerciseManager({ exercise: initial, sessions, assignedU
)}
- - {/* Actions */} -
-
Actions
-
- - View Submissions - - - Export CSV - -
-
); } diff --git a/app/instructor/exercises/[id]/page.tsx b/app/instructor/exercises/[id]/page.tsx index 439ae88..1ceb8fb 100644 --- a/app/instructor/exercises/[id]/page.tsx +++ b/app/instructor/exercises/[id]/page.tsx @@ -19,11 +19,16 @@ export default async function ExercisePage({ params }: Props) { const exerciseRows = await sql` SELECT e.id, e.slug, e.title, e.enabled, e.question_count, e.start_time, e.end_time, e.duration_limit, + e.pass_mark, e.min_questions_required, e.flag_fails, e.max_paste_chars, e.max_focus_loss, + e.min_edit_events, e.min_response_length, COALESCE(json_agg(ea.user_id) FILTER (WHERE ea.user_id IS NOT NULL), '[]') AS assigned_user_ids FROM exercises e LEFT JOIN exercise_assignments ea ON ea.exercise_id = e.id WHERE e.id = ${id} - GROUP BY e.id, e.slug, e.title, e.enabled, e.question_count, e.start_time, e.end_time, e.duration_limit + GROUP BY e.id, e.slug, e.title, e.enabled, e.question_count, + e.start_time, e.end_time, e.duration_limit, + e.pass_mark, e.min_questions_required, e.flag_fails, e.max_paste_chars, e.max_focus_loss, + e.min_edit_events, e.min_response_length `; if (exerciseRows.length === 0) notFound(); @@ -40,6 +45,13 @@ export default async function ExercisePage({ params }: Props) { start_time: raw.start_time ? String(raw.start_time) : null, end_time: raw.end_time ? String(raw.end_time) : null, duration_limit: raw.duration_limit ? intervalToString(raw.duration_limit) : null, + pass_mark: raw.pass_mark != null ? Number(raw.pass_mark) : null, + min_questions_required: raw.min_questions_required != null ? Number(raw.min_questions_required) : null, + flag_fails: (raw.flag_fails as boolean) ?? false, + max_paste_chars: raw.max_paste_chars != null ? Number(raw.max_paste_chars) : null, + max_focus_loss: raw.max_focus_loss != null ? Number(raw.max_focus_loss) : null, + min_edit_events: raw.min_edit_events != null ? Number(raw.min_edit_events) : null, + min_response_length: raw.min_response_length != null ? Number(raw.min_response_length) : null, }; const sessionRows = await sql` @@ -88,12 +100,6 @@ export default async function ExercisePage({ params }: Props) { {' · '}{exercise.question_count} question{exercise.question_count > 1 ? 's' : ''}

- {/* Question management */}
@@ -107,6 +113,13 @@ export default async function ExercisePage({ params }: Props) { initialQuestions={questionRows as { id: string; question_index: number; text: string; type: 'written' | 'code'; language: string; starter: string }[]} />
+ + diff --git a/app/instructor/exercises/[id]/submissions/SubmissionsTable.tsx b/app/instructor/exercises/[id]/submissions/SubmissionsTable.tsx index 85c9072..97f4ae3 100644 --- a/app/instructor/exercises/[id]/submissions/SubmissionsTable.tsx +++ b/app/instructor/exercises/[id]/submissions/SubmissionsTable.tsx @@ -2,26 +2,30 @@ import { useState } from 'react'; import Link from 'next/link'; -import { Flag } from 'lucide-react'; +import { Flag, ChevronDown, ChevronRight } from 'lucide-react'; import SearchInput from '@/app/components/SearchInput'; +import type { ParticipantRow } from './page'; -interface SubmissionRow { - id: string; session_id: string; question_index: number; - is_final: boolean; is_flagged: boolean; flag_reasons: string[] | null; - submitted_at: string; username: string; -} - -export default function SubmissionsTable({ submissions }: { submissions: SubmissionRow[] }) { +export default function SubmissionsTable({ participants }: { participants: ParticipantRow[] }) { const [search, setSearch] = useState(''); const [filterFlagged, setFilterFlagged] = useState(false); + const [expanded, setExpanded] = useState>(new Set()); - const filtered = submissions.filter((s) => { - const matchSearch = s.username.toLowerCase().includes(search.toLowerCase()); - const matchFlag = !filterFlagged || s.is_flagged; + const filtered = participants.filter((p) => { + const matchSearch = p.username.toLowerCase().includes(search.toLowerCase()); + const matchFlag = !filterFlagged || p.is_flagged; return matchSearch && matchFlag; }); - const flagged = submissions.filter((s) => s.is_flagged).length; + const flaggedCount = participants.filter((p) => p.is_flagged).length; + + function toggleExpand(sessionId: string) { + setExpanded((prev) => { + const next = new Set(prev); + next.has(sessionId) ? next.delete(sessionId) : next.add(sessionId); + return next; + }); + } return ( <> @@ -31,56 +35,111 @@ export default function SubmissionsTable({ submissions }: { submissions: Submiss className={`btn btn-sm ${filterFlagged ? 'btn-danger' : 'btn-ghost'}`} onClick={() => setFilterFlagged((f) => !f)} > - {filterFlagged ? `Flagged (${flagged})` : `Show flagged only`} + {filterFlagged ? `Flagged (${flaggedCount})` : 'Show flagged only'} - {filtered.length} of {submissions.length} + {filtered.length} of {participants.length} {filtered.length === 0 ? (
- {search || filterFlagged ? 'No submissions match your filter.' : 'No submissions yet.'} + {search || filterFlagged ? 'No participants match your filter.' : 'No submissions yet.'}
) : (
+ - - + - + + - {filtered.map((sub) => ( - - - - - - - - - ))} + {filtered.map((p) => { + const isExpanded = expanded.has(p.session_id); + const passing = p.passed; + + return ( + <> + {/* Summary row */} + 0 ? 'pointer' : 'default' }} + onClick={() => p.submissions.length > 0 && toggleExpand(p.session_id)} + > + + + + + + + + + {/* Expanded question rows */} + {isExpanded && p.submissions.map((sub) => ( + + + + + + + + ))} + + ); + })}
ParticipantQ#StatusProgress FlagSubmittedScoreLast Activity Actions
{sub.username}{sub.question_index + 1} - - {sub.is_final ? 'Final' : 'Draft'} - - - {sub.is_flagged - ? Flagged - : - } - - {new Date(sub.submitted_at).toLocaleString()} - - - Review - -
+ {p.submissions.length > 0 + ? isExpanded ? : + : null} + {p.username} + + {p.final_count} + /{p.total_questions} + + {p.final_count === p.total_questions && ( + Done + )} + + {p.is_flagged + ? + {p.flag_count} + + : + } + + {p.score !== null ? `${p.score.toFixed(1)}%` : '—'} + {passing === true && } + {passing === false && } + {p.last_submitted_at} e.stopPropagation()} /> +
+ + Q{sub.question_index + 1} + + + {sub.is_final ? 'Final' : 'Draft'} + + + {sub.is_flagged + ? Flagged + : + } + + + {new Date(sub.submitted_at).toLocaleTimeString()} + + + Review + +
diff --git a/app/instructor/exercises/[id]/submissions/page.tsx b/app/instructor/exercises/[id]/submissions/page.tsx index a71778e..85935e0 100644 --- a/app/instructor/exercises/[id]/submissions/page.tsx +++ b/app/instructor/exercises/[id]/submissions/page.tsx @@ -3,36 +3,89 @@ import { notFound } from 'next/navigation'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { sql } from '@/lib/db'; +import { formatWAT } from '@/lib/format'; import Navbar from '@/app/components/Navbar'; import SubmissionsTable from './SubmissionsTable'; interface Props { params: { id: string }; } -interface SubmissionRow { - id: string; session_id: string; question_index: number; - is_final: boolean; is_flagged: boolean; flag_reasons: string[] | null; - submitted_at: string; username: string; + +export interface ParticipantRow { + session_id: string; + username: string; + score: number | null; + passed: boolean | null; + total_questions: number; + answered: number; + final_count: number; + is_flagged: boolean; + flag_count: number; + last_submitted_at: string; + // individual submissions for drill-down + submissions: { + id: string; + question_index: number; + is_final: boolean; + is_flagged: boolean; + submitted_at: string; + }[]; } export default async function SubmissionListPage({ params }: Props) { const { id: exerciseId } = params; const session = await getServerSession(authOptions); - const exerciseRows = await sql`SELECT id, title FROM exercises WHERE id = ${exerciseId}`; + const exerciseRows = await sql`SELECT id, title, pass_mark, question_count FROM exercises WHERE id = ${exerciseId}`; if (exerciseRows.length === 0) notFound(); - const exercise = exerciseRows[0] as { id: string; title: string }; + const exercise = exerciseRows[0] as { id: string; title: string; pass_mark: number | null; question_count: number }; + // One row per session (participant), aggregated const rows = await sql` - SELECT sub.id, sub.session_id, sub.question_index, sub.is_final, sub.is_flagged, - sub.flag_reasons, sub.submitted_at, u.username - FROM submissions sub - JOIN sessions s ON s.id = sub.session_id + SELECT + s.id AS session_id, + u.username, + s.score, + s.passed, + 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, + BOOL_OR(sub.is_flagged) AS is_flagged, + COUNT(sub.id) FILTER (WHERE sub.is_flagged = true)::int AS flag_count, + MAX(sub.submitted_at) AS last_submitted_at, + JSON_AGG( + JSON_BUILD_OBJECT( + 'id', sub.id, + 'question_index', sub.question_index, + 'is_final', sub.is_final, + 'is_flagged', sub.is_flagged, + 'submitted_at', sub.submitted_at + ) ORDER BY sub.question_index + ) FILTER (WHERE sub.id IS NOT NULL) AS submissions + FROM sessions s JOIN users u ON u.id = s.user_id + JOIN exercises e ON e.id = s.exercise_id + LEFT JOIN submissions sub ON sub.session_id = s.id WHERE s.exercise_id = ${exerciseId} - ORDER BY u.username, sub.question_index + GROUP BY s.id, u.username, s.score, s.passed, e.question_count + ORDER BY u.username `; - const submissions = rows as unknown as SubmissionRow[]; - const flagged = submissions.filter((s) => s.is_flagged).length; - const final = submissions.filter((s) => s.is_final).length; + + const participants: ParticipantRow[] = (rows as Record[]).map((r) => ({ + session_id: r.session_id as string, + username: r.username as string, + score: r.score != null ? Number(r.score) : null, + passed: r.passed != null ? (r.passed 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), + submissions: (r.submissions as { id: string; question_index: number; is_final: boolean; is_flagged: boolean; submitted_at: string }[] | null) ?? [], + })); + + const totalParticipants = participants.length; + const flaggedParticipants = participants.filter((p) => p.is_flagged).length; + const completedParticipants = participants.filter((p) => p.final_count === p.total_questions).length; return (
@@ -59,21 +112,21 @@ export default async function SubmissionListPage({ params }: Props) {
- Total - {submissions.length} + Participants + {totalParticipants}
- Final - {final} + Completed + {completedParticipants}
Flagged - {flagged} + {flaggedParticipants}
- +
diff --git a/app/instructor/feedback/page.tsx b/app/instructor/feedback/page.tsx index 9d264c3..4275d68 100644 --- a/app/instructor/feedback/page.tsx +++ b/app/instructor/feedback/page.tsx @@ -5,6 +5,8 @@ import { sql } from '@/lib/db'; import Navbar from '@/app/components/Navbar'; import Link from 'next/link'; +import { formatWAT } from '@/lib/format'; + export default async function FeedbackPage() { const session = await getServerSession(authOptions); if (session?.user?.role !== 'instructor') redirect('/login'); @@ -85,7 +87,7 @@ export default async function FeedbackPage() { )} - {new Date(f.submitted_at as string).toLocaleString()} + {formatWAT(f.submitted_at as string)} diff --git a/app/instructor/page.tsx b/app/instructor/page.tsx index da55e0a..adf3d02 100644 --- a/app/instructor/page.tsx +++ b/app/instructor/page.tsx @@ -5,6 +5,7 @@ import { sql } from '@/lib/db'; import LiveMonitor from './LiveMonitor'; import CreateExercise from './CreateExercise'; import ExercisesTable from './ExercisesTable'; +import AnalyticsPanel from './AnalyticsPanel'; import Navbar from '@/app/components/Navbar'; import { Radio, Users } from 'lucide-react'; @@ -17,6 +18,18 @@ interface Exercise { assigned_user_ids: string[]; } +interface ExerciseAnalytics { + exercise_id: string; + title: string; + total_sessions: number; + completed_sessions: number; + passed_sessions: number; + failed_sessions: number; + flagged_sessions: number; + avg_score: number | null; + avg_questions_answered: number | null; +} + async function getExercises(): Promise { try { const rows = await sql` @@ -33,9 +46,37 @@ async function getExercises(): Promise { } } +async function getAnalytics(): Promise { + try { + const rows = await sql` + SELECT + e.id AS exercise_id, + e.title, + COUNT(s.id)::int AS total_sessions, + SUM(CASE WHEN s.closed_at IS NOT NULL THEN 1 ELSE 0 END)::int AS completed_sessions, + SUM(CASE WHEN s.passed = true THEN 1 ELSE 0 END)::int AS passed_sessions, + SUM(CASE WHEN s.passed = false THEN 1 ELSE 0 END)::int AS failed_sessions, + SUM(CASE WHEN EXISTS ( + SELECT 1 FROM submissions sub WHERE sub.session_id = s.id AND sub.is_flagged = true + ) THEN 1 ELSE 0 END)::int AS flagged_sessions, + ROUND(AVG(s.score)::numeric, 1) AS avg_score, + ROUND(AVG( + (SELECT SUM(CASE WHEN sub.is_final THEN 1 ELSE 0 END) FROM submissions sub WHERE sub.session_id = s.id) + )::numeric, 1) AS avg_questions_answered + FROM exercises e + LEFT JOIN sessions s ON s.exercise_id = e.id + GROUP BY e.id, e.title + ORDER BY e.title + `; + return rows as unknown as ExerciseAnalytics[]; + } catch { + return []; + } +} + export default async function InstructorDashboard() { const session = await getServerSession(authOptions); - const exercises = await getExercises(); + const [exercises, analytics] = await Promise.all([getExercises(), getAnalytics()]); const enabled = exercises.filter((e) => e.enabled).length; const totalAssigned = exercises.reduce((s, e) => s + e.assigned_user_ids.length, 0); @@ -46,6 +87,13 @@ export default async function InstructorDashboard() { participantCount = (r[0]?.count as number) ?? 0; } catch { /* ignore */ } + // Cohort-level totals + const totalSessions = analytics.reduce((s, a) => s + a.total_sessions, 0); + const totalCompleted = analytics.reduce((s, a) => s + a.completed_sessions, 0); + const totalPassed = analytics.reduce((s, a) => s + a.passed_sessions, 0); + const totalFailed = analytics.reduce((s, a) => s + a.failed_sessions, 0); + const totalFlagged = analytics.reduce((s, a) => s + a.flagged_sessions, 0); + return (
+ {/* Cohort Analytics */} + {totalSessions > 0 && ( + + )} + {/* Live monitor */} diff --git a/app/instructor/submissions/[id]/FlagDismissal.tsx b/app/instructor/submissions/[id]/FlagDismissal.tsx new file mode 100644 index 0000000..9e858cd --- /dev/null +++ b/app/instructor/submissions/[id]/FlagDismissal.tsx @@ -0,0 +1,108 @@ +'use client'; + +import { useState } from 'react'; +import { Flag, X, RotateCcw } from 'lucide-react'; +import { toast } from 'sonner'; + +interface DismissedFlag { + reason: string; + dismissed_by: string; + dismissed_at: string; + dismissed_by_username?: string; +} + +interface Props { + submissionId: string; + flagReasons: string[]; + dismissedFlags: DismissedFlag[]; +} + +export default function FlagDismissal({ submissionId, flagReasons, dismissedFlags: initial }: Props) { + const [dismissedFlags, setDismissedFlags] = useState(initial); + const [loading, setLoading] = useState(null); + + const dismissedReasons = new Set(dismissedFlags.map((d) => d.reason)); + + async function toggle(reason: string, restore: boolean) { + setLoading(reason); + try { + const res = await fetch(`/api/instructor/submissions/${submissionId}/dismiss-flag`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason, restore }), + }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error ?? 'Request failed'); + } + const data = await res.json(); + setDismissedFlags(data.dismissed_flags ?? []); + toast.success(restore ? 'Flag restored' : 'Flag dismissed'); + } catch (e: unknown) { + toast.error(e instanceof Error ? e.message : 'Unknown error'); + } finally { + setLoading(null); + } + } + + if (flagReasons.length === 0) return null; + + return ( +
+ {flagReasons.map((reason) => { + const dismissed = dismissedFlags.find((d) => d.reason === reason); + const isDismissed = dismissedReasons.has(reason); + const isLoading = loading === reason; + + return ( +
+
+ +
+ + {reason} + + {dismissed && ( +
+ Dismissed {new Date(dismissed.dismissed_at).toLocaleString()} +
+ )} +
+
+ +
+ ); + })} +
+ ); +} diff --git a/app/instructor/submissions/[id]/page.tsx b/app/instructor/submissions/[id]/page.tsx index 337ad4b..9577863 100644 --- a/app/instructor/submissions/[id]/page.tsx +++ b/app/instructor/submissions/[id]/page.tsx @@ -3,21 +3,23 @@ import { notFound } from 'next/navigation'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { sql } from '@/lib/db'; +import { Flag } from 'lucide-react'; import TypingReplay from './TypingReplay'; import ReviewNote from './ReviewNote'; +import FlagDismissal from './FlagDismissal'; import Navbar from '@/app/components/Navbar'; +import { formatWAT } from '@/lib/format'; interface Props { params: { id: string }; } -import { Flag, Check } from 'lucide-react'; - export default async function SubmissionDetailPage({ params }: Props) { const { id: submissionId } = params; const session = await getServerSession(authOptions); const subRows = await sql` SELECT sub.id, sub.session_id, sub.question_index, sub.response_text, - sub.is_final, sub.is_flagged, sub.flag_reasons, sub.review_note, sub.submitted_at, + sub.is_final, sub.is_flagged, sub.flag_reasons, sub.dismissed_flags, + sub.review_note, sub.submitted_at, s.exercise_id, u.username FROM submissions sub JOIN sessions s ON s.id = sub.session_id @@ -29,12 +31,13 @@ export default async function SubmissionDetailPage({ params }: Props) { const sub = subRows[0] as { id: string; session_id: string; question_index: number; response_text: string; is_final: boolean; is_flagged: boolean; flag_reasons: string[] | null; + dismissed_flags: { reason: string; dismissed_by: string; dismissed_at: string }[]; review_note: string | null; submitted_at: string; exercise_id: string; username: string; }; const [autosaveRows, pasteRows, focusRows, editRows] = await Promise.all([ sql`SELECT id, response_text, saved_at FROM autosave_history WHERE submission_id = ${submissionId} ORDER BY saved_at ASC`, - sql`SELECT id, char_count, occurred_at FROM paste_events WHERE submission_id = ${submissionId} ORDER BY occurred_at ASC`, + sql`SELECT id, char_count, pasted_text, occurred_at FROM paste_events WHERE submission_id = ${submissionId} ORDER BY occurred_at ASC`, sql`SELECT id, lost_at, regained_at, duration_ms FROM focus_events WHERE session_id = ${sub.session_id} ORDER BY lost_at ASC`, sql`SELECT id, event_type, position, char_count, occurred_at FROM edit_events WHERE submission_id = ${submissionId} ORDER BY occurred_at ASC`, ]); @@ -59,7 +62,7 @@ export default async function SubmissionDetailPage({ params }: Props) {

{sub.username}

-

Question {sub.question_index + 1} · {new Date(sub.submitted_at).toLocaleString()}

+

Question {sub.question_index + 1} · {formatWAT(sub.submitted_at)}

{sub.is_flagged && Flagged} @@ -72,9 +75,17 @@ export default async function SubmissionDetailPage({ params }: Props) {
- {sub.is_flagged && sub.flag_reasons && sub.flag_reasons.length > 0 && ( -
- Flag reasons: {sub.flag_reasons.join(' · ')} + {sub.flag_reasons && sub.flag_reasons.length > 0 && ( +
+
+ Flags + {sub.flag_reasons.length - (sub.dismissed_flags?.length ?? 0)} active +
+
)} @@ -128,13 +139,16 @@ export default async function SubmissionDetailPage({ params }: Props) {
- + - {(pasteRows as { id: string; char_count: number; occurred_at: string }[]).map((ev, i) => ( + {(pasteRows as { id: string; char_count: number; pasted_text: string | null; occurred_at: string }[]).map((ev, i) => ( - + + ))} @@ -157,8 +171,8 @@ export default async function SubmissionDetailPage({ params }: Props) { {(focusRows as { id: string; lost_at: string; regained_at: string | null; duration_ms: number | null }[]).map((ev, i) => ( - - + + diff --git a/app/instructor/users/UserManager.tsx b/app/instructor/users/UserManager.tsx index 76d74d6..d625a17 100644 --- a/app/instructor/users/UserManager.tsx +++ b/app/instructor/users/UserManager.tsx @@ -6,6 +6,8 @@ import { toast } from 'sonner'; import { Plus, X, KeyRound, Trash2, AlertTriangle } from 'lucide-react'; import SearchInput from '@/app/components/SearchInput'; +import { formatDateWAT } from '@/lib/format'; + interface User { id: string; username: string; role: string; created_at: string; } interface Props { initialUsers: User[]; currentUserId: string; } @@ -140,7 +142,7 @@ export default function UserManager({ initialUsers, currentUserId }: Props) { - +
#Chars PastedOccurred At
#Chars PastedPasted TextOccurred At
{i + 1} {ev.char_count}{new Date(ev.occurred_at).toLocaleString()} + {ev.pasted_text ?? } + {formatWAT(ev.occurred_at)}
{i + 1}{new Date(ev.lost_at).toLocaleString()}{ev.regained_at ? new Date(ev.regained_at).toLocaleString() : '—'}{formatWAT(ev.lost_at)}{ev.regained_at ? formatWAT(ev.regained_at) : '—'} {ev.duration_ms != null ? `${(ev.duration_ms / 1000).toFixed(1)}s` : '—'}
{user.username} {user.role}{new Date(user.created_at).toLocaleDateString()}{formatDateWAT(user.created_at)}
)} + + {isViewingCurrent && sessionState.current_question_index + 1 === sessionState.question_count && !sessionClosed && ( +
+ {advanceError && {advanceError}} + +
+ )} ); diff --git a/lib/flagging.ts b/lib/flagging.ts index df27c47..5bc4c10 100644 --- a/lib/flagging.ts +++ b/lib/flagging.ts @@ -14,23 +14,46 @@ const MIN_RESPONSE_LENGTH_FOR_EDIT_CHECK = parseInt(process.env.FLAG_MIN_RESPONS * whether it should be flagged and the reasons why. * * Flags if: - * - paste events > 0 + * - paste events with char_count > max_paste_chars threshold (or any paste if no threshold) * - focus-loss events > FOCUS_LOSS_THRESHOLD (default 3) * - edit events < 10 for a response longer than 200 characters */ export async function evaluateFlags(submissionId: string): Promise { const flag_reasons: string[] = []; - // Count paste events for this submission - const pasteResult = await sql` - SELECT COUNT(*)::int AS count - FROM paste_events - WHERE submission_id = ${submissionId} + // Look up the exercise's max_paste_chars threshold for this submission + const thresholdResult = await sql` + SELECT e.max_paste_chars, e.max_focus_loss, e.min_edit_events, e.min_response_length + FROM submissions sub + JOIN sessions sess ON sess.id = sub.session_id + JOIN exercises e ON e.id = sess.exercise_id + WHERE sub.id = ${submissionId} + LIMIT 1 `; + const maxPasteChars: number | null = (thresholdResult[0]?.max_paste_chars as number | null) ?? null; + const maxFocusLoss: number | null = (thresholdResult[0]?.max_focus_loss as number | null) ?? null; + 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; + + // Count paste events that exceed the threshold (or all paste events if no threshold set) + const pasteResult = maxPasteChars !== null + ? await sql` + SELECT COUNT(*)::int AS count + FROM paste_events + WHERE submission_id = ${submissionId} + AND char_count > ${maxPasteChars} + ` + : await sql` + SELECT COUNT(*)::int AS count + FROM paste_events + WHERE submission_id = ${submissionId} + `; const pasteCount: number = (pasteResult[0]?.count as number) ?? 0; if (pasteCount > 0) { - flag_reasons.push(`paste_detected: ${pasteCount} paste event(s) recorded`); + const thresholdNote = maxPasteChars !== null ? ` (>${maxPasteChars} chars each)` : ''; + flag_reasons.push(`paste_detected: ${pasteCount} paste event(s) recorded${thresholdNote}`); } // Count focus-loss events for the session that owns this submission @@ -42,9 +65,9 @@ export async function evaluateFlags(submissionId: string): Promise { `; const focusLossCount: number = (focusResult[0]?.count as number) ?? 0; - if (focusLossCount > FOCUS_LOSS_THRESHOLD) { + if (focusLossCount > focusLossThreshold) { flag_reasons.push( - `focus_loss_exceeded: ${focusLossCount} focus-loss event(s) (threshold: ${FOCUS_LOSS_THRESHOLD})` + `focus_loss_exceeded: ${focusLossCount} focus-loss event(s) (threshold: ${focusLossThreshold})` ); } @@ -62,23 +85,20 @@ export async function evaluateFlags(submissionId: string): Promise { const responseText: string = (editResult[0].response_text as string) ?? ''; if ( - responseText.length > MIN_RESPONSE_LENGTH_FOR_EDIT_CHECK && - editCount < MIN_EDIT_EVENTS + responseText.length > minResponseLength && + editCount < minEditEvents ) { flag_reasons.push( `low_edit_count: only ${editCount} edit event(s) for a ${responseText.length}-character response` ); } } else { - // No edit events at all — check if the response is long enough to warrant flagging const submissionResult = await sql` - SELECT response_text - FROM submissions - WHERE id = ${submissionId} + SELECT response_text FROM submissions WHERE id = ${submissionId} `; const responseText: string = (submissionResult[0]?.response_text as string) ?? ''; - if (responseText.length > MIN_RESPONSE_LENGTH_FOR_EDIT_CHECK) { + if (responseText.length > minResponseLength) { flag_reasons.push( `low_edit_count: 0 edit event(s) for a ${responseText.length}-character response` ); diff --git a/lib/format.ts b/lib/format.ts new file mode 100644 index 0000000..04558b0 --- /dev/null +++ b/lib/format.ts @@ -0,0 +1,84 @@ +/** + * WAT (West Africa Time) formatting utilities. + * Africa/Lagos is permanently UTC+1 — no DST. + * All timestamps are stored as UTC in the DB; these helpers convert for display only. + */ + +const WAT_LOCALE = 'en-NG'; +const WAT_TZ = 'Africa/Lagos'; + +/** + * Format a UTC ISO string (or Date) as a full datetime in WAT. + * e.g. "21/04/2026, 14:30:00 WAT" + */ +export function formatWAT( + iso: string | Date, + opts?: Intl.DateTimeFormatOptions +): string { + try { + const date = typeof iso === 'string' ? new Date(iso) : iso; + if (isNaN(date.getTime())) return 'Invalid Date WAT'; + const base: Intl.DateTimeFormatOptions = { + timeZone: WAT_TZ, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: true, + ...opts, + }; + return date.toLocaleString(WAT_LOCALE, base) + ' WAT'; + } catch { + return 'Invalid Date WAT'; + } +} + +/** + * Format a UTC ISO string (or Date) as a date-only string in WAT. + * e.g. "21/04/2026" + */ +export function formatDateWAT( + iso: string | Date, + opts?: Intl.DateTimeFormatOptions +): string { + try { + const date = typeof iso === 'string' ? new Date(iso) : iso; + if (isNaN(date.getTime())) return 'Invalid Date'; + return date.toLocaleDateString(WAT_LOCALE, { + timeZone: WAT_TZ, + year: 'numeric', + month: '2-digit', + day: '2-digit', + ...opts, + }); + } catch { + return 'Invalid Date'; + } +} + +/** + * Format a UTC ISO string (or Date) as a time-only string in WAT. + * e.g. "14:30:00 WAT" + */ +export function formatTimeWAT( + iso: string | Date, + opts?: Intl.DateTimeFormatOptions +): string { + try { + const date = typeof iso === 'string' ? new Date(iso) : iso; + if (isNaN(date.getTime())) return 'Invalid Date WAT'; + const base: Intl.DateTimeFormatOptions = { + timeZone: WAT_TZ, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: true, + ...opts, + }; + return date.toLocaleTimeString(WAT_LOCALE, base) + ' WAT'; + } catch { + return 'Invalid Date WAT'; + } +} diff --git a/lib/scoring.ts b/lib/scoring.ts new file mode 100644 index 0000000..dba48b9 --- /dev/null +++ b/lib/scoring.ts @@ -0,0 +1,117 @@ +import { sql } from './db'; + +const FLAG_PENALTY = parseFloat(process.env.FLAG_PENALTY ?? '10'); + +export interface ScoreResult { + score: number; + passed: boolean | null; // null = no constraints configured +} + +/** + * Recalculates and persists the score and pass/fail status for a session. + * + * Score formula: max(0, min(100, (finalSubmissions / totalQuestions) * 100 - activeFlagReasons * FLAG_PENALTY)) + * + * Pass/fail constraints (all must pass if configured): + * - min_questions_required: finalCount >= threshold + * - flag_fails: session has no active (non-dismissed) flags + * - max_paste_chars: total pasted chars across all paste events <= threshold + * - pass_mark: score >= pass_mark + */ +export async function recalculateSessionScore(sessionId: string): Promise { + // 1. Get exercise config + session info + const sessionRows = await sql` + SELECT e.question_count, e.pass_mark, + e.min_questions_required, e.flag_fails, e.max_paste_chars, e.max_focus_loss + FROM sessions s + JOIN exercises e ON e.id = s.exercise_id + WHERE s.id = ${sessionId} + LIMIT 1 + `; + if (sessionRows.length === 0) throw new Error('Session not found'); + + const cfg = sessionRows[0] as { + question_count: number; + pass_mark: number | null; + min_questions_required: number | null; + flag_fails: boolean; + max_paste_chars: number | null; + max_focus_loss: number | null; + }; + + const totalQuestions = cfg.question_count ?? 0; + + if (totalQuestions === 0) { + await sql`UPDATE sessions SET score = 0, passed = NULL WHERE id = ${sessionId}`; + return { score: 0, passed: null }; + } + + // 2. Count final submissions + const finalRows = await sql` + SELECT SUM(CASE WHEN is_final THEN 1 ELSE 0 END)::int AS count + FROM submissions + WHERE session_id = ${sessionId} + `; + const finalCount = (finalRows[0]?.count as number) ?? 0; + + // 3. Count active (non-dismissed) flag reasons + const flagRows = await sql` + SELECT + COALESCE(array_length(flag_reasons, 1), 0) AS total_reasons, + jsonb_array_length(dismissed_flags) AS dismissed_count + FROM submissions + WHERE session_id = ${sessionId} + `; + + let activeFlagReasonCount = 0; + let hasActiveFlag = false; + for (const row of flagRows) { + const total = (row.total_reasons as number) ?? 0; + const dismissed = (row.dismissed_count as number) ?? 0; + const active = Math.max(0, total - dismissed); + activeFlagReasonCount += active; + if (active > 0) hasActiveFlag = true; + } + + // 4. Total pasted chars + const pasteRows = await sql` + SELECT COALESCE(SUM(char_count), 0)::int AS total_chars + FROM paste_events pe + JOIN submissions sub ON sub.id = pe.submission_id + WHERE sub.session_id = ${sessionId} + `; + const totalPasteChars = (pasteRows[0]?.total_chars as number) ?? 0; + + // 4b. Focus loss count + const focusRows = await sql` + SELECT COUNT(*)::int AS count FROM focus_events WHERE session_id = ${sessionId} + `; + const focusLossCount = (focusRows[0]?.count as number) ?? 0; + + // 5. Score formula + const raw = (finalCount / totalQuestions) * 100 - activeFlagReasonCount * FLAG_PENALTY; + const score = Math.min(100, Math.max(0, raw)); + + // 6. Evaluate pass/fail constraints + const hasAnyConstraint = + cfg.pass_mark !== null || + cfg.min_questions_required !== null || + cfg.flag_fails || + cfg.max_paste_chars !== null || + cfg.max_focus_loss !== null; + + let passed: boolean | null = null; + if (hasAnyConstraint) { + passed = true; + if (cfg.pass_mark !== null && score < cfg.pass_mark) passed = false; + if (cfg.min_questions_required !== null && finalCount < cfg.min_questions_required) passed = false; + if (cfg.flag_fails && hasActiveFlag) passed = false; + if (cfg.max_paste_chars !== null && totalPasteChars > cfg.max_paste_chars) passed = false; + if (cfg.max_focus_loss !== null && focusLossCount > cfg.max_focus_loss) passed = false; + } + + // 7. Persist + await sql`UPDATE sessions SET score = ${score}, passed = ${passed} WHERE id = ${sessionId}`; + + return { score, passed }; +} diff --git a/migrations/0008_platform_improvements.sql b/migrations/0008_platform_improvements.sql new file mode 100644 index 0000000..737236a --- /dev/null +++ b/migrations/0008_platform_improvements.sql @@ -0,0 +1,19 @@ +-- Migration 0008: platform improvements +-- Safe for production: all changes are additive (IF NOT EXISTS / DEFAULT values) + +-- 1. Capture exact pasted text in paste events +ALTER TABLE paste_events + ADD COLUMN IF NOT EXISTS pasted_text TEXT; + +-- 2. Pass mark threshold per exercise (nullable = no threshold) +ALTER TABLE exercises + ADD COLUMN IF NOT EXISTS pass_mark NUMERIC; + +-- 3. Computed score per session (nullable until first calculation) +ALTER TABLE sessions + ADD COLUMN IF NOT EXISTS score NUMERIC; + +-- 4. Flag dismissal audit overlay on submissions +-- Shape: [{ reason, dismissed_by, dismissed_at }] +ALTER TABLE submissions + ADD COLUMN IF NOT EXISTS dismissed_flags JSONB NOT NULL DEFAULT '[]'::jsonb; diff --git a/package-lock.json b/package-lock.json index 690204b..f0fcec7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "react": "^18", "react-dom": "^18", "react-markdown": "^10.1.0", + "recharts": "^3.8.1", "sonner": "^2.0.7" }, "devDependencies": { @@ -1030,6 +1031,42 @@ "node": ">=14" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", @@ -1401,6 +1438,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -1435,6 +1484,69 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -1514,12 +1626,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1536,20 +1650,18 @@ "@types/react": "^18.0.0" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/parser": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.2.0.tgz", @@ -2700,6 +2812,15 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2772,6 +2893,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, "license": "MIT" }, "node_modules/csv-stringify": { @@ -2780,6 +2902,127 @@ "integrity": "sha512-UdtziYp5HuTz7e5j8Nvq+a/3HQo+2/aJZ9xntNTpmRRIg/3YYqDVgiS9fvAhtNbnyfbv2ZBe0bqCHqzhE7FqWQ==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -2858,6 +3101,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -2985,16 +3234,6 @@ "node": ">=6.0.0" } }, - "node_modules/dompurify": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", - "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", - "license": "(MPL-2.0 OR Apache-2.0)", - "peer": true, - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3202,6 +3441,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.0.tgz", + "integrity": "sha512-IToJ6ct9OLl5zz6WsC/1vZEwfSZ7Myil+ygl5Tf30Xjn9AEkzNB4kqp2G7VUJKF1DtTx/ra5M5KLlXvzOg51BA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -3693,6 +3942,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/execa": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", @@ -4445,6 +4700,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4512,6 +4777,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -5297,19 +5571,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/marked": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", - "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", - "license": "MIT", - "peer": true, - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6025,17 +6286,6 @@ "dev": true, "license": "MIT" }, - "node_modules/monaco-editor": { - "version": "0.55.1", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", - "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", - "license": "MIT", - "peer": true, - "dependencies": { - "dompurify": "3.2.7", - "marked": "14.0.0" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6976,6 +7226,74 @@ "react": ">=18" } }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -7053,6 +7371,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "2.0.0-next.6", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", @@ -7969,6 +8293,12 @@ "dev": true, "license": "MIT" }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -8823,6 +9153,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -8860,6 +9199,28 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/package.json b/package.json index e7b3f39..0730a7d 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "react": "^18", "react-dom": "^18", "react-markdown": "^10.1.0", + "recharts": "^3.8.1", "sonner": "^2.0.7" }, "devDependencies": { From 2680ada7013b260068b10d99cdd395aeaeda493c Mon Sep 17 00:00:00 2001 From: jvcByte Date: Mon, 27 Apr 2026 14:33:53 +0100 Subject: [PATCH 2/2] Fix build: install monaco-editor types and fix recharts formatter type --- app/instructor/AnalyticsPanel.tsx | 2 +- package-lock.json | 43 +++++++++++++++++++++++++++++++ package.json | 1 + 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/app/instructor/AnalyticsPanel.tsx b/app/instructor/AnalyticsPanel.tsx index f16134d..2b61685 100644 --- a/app/instructor/AnalyticsPanel.tsx +++ b/app/instructor/AnalyticsPanel.tsx @@ -208,7 +208,7 @@ export default function AnalyticsPanel({ payload?.[0]?.payload?.fullTitle ?? ''} - formatter={(v: number) => [`${v.toFixed(1)}%`, 'Avg Score']} + formatter={(v) => v != null ? [`${Number(v).toFixed(1)}%`, 'Avg Score'] : ['—', 'Avg Score']} /> diff --git a/package-lock.json b/package-lock.json index f0fcec7..301b09e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "eslint": "^8", "eslint-config-next": "14.2.5", "fast-check": "^3.19.0", + "monaco-editor": "^0.55.1", "tsx": "^4.21.0", "typescript": "^5", "vitest": "^1.6.0" @@ -1650,6 +1651,14 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -3234,6 +3243,16 @@ "node": ">=6.0.0" } }, + "node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "dev": true, + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -5571,6 +5590,19 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6286,6 +6318,17 @@ "dev": true, "license": "MIT" }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/package.json b/package.json index 0730a7d..9b574ac 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "eslint": "^8", "eslint-config-next": "14.2.5", "fast-check": "^3.19.0", + "monaco-editor": "^0.55.1", "tsx": "^4.21.0", "typescript": "^5", "vitest": "^1.6.0"