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
+
+)}
+```
+
+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..2b61685
--- /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 (
+