From 3de1fe5647d4a5f4951797c4c294e3932db826a4 Mon Sep 17 00:00:00 2001 From: jvcByte Date: Fri, 15 May 2026 15:56:11 +0100 Subject: [PATCH 1/2] feat: implement history viewing --- app/instructor/page.tsx | 75 +++++++++++++++ app/participant/page.tsx | 40 +++++++- lib/history-db.ts | 160 ++++++++++++++++++++++++++++++++ migrations/reset_keep_users.sql | 20 ++++ package.json | 1 + 5 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 lib/history-db.ts create mode 100644 migrations/reset_keep_users.sql diff --git a/app/instructor/page.tsx b/app/instructor/page.tsx index adf3d02..6b874b3 100644 --- a/app/instructor/page.tsx +++ b/app/instructor/page.tsx @@ -8,6 +8,8 @@ import ExercisesTable from './ExercisesTable'; import AnalyticsPanel from './AnalyticsPanel'; import Navbar from '@/app/components/Navbar'; import { Radio, Users } from 'lucide-react'; +import { getAllHistoryResults, type HistorySession } from '@/lib/history-db'; +import { formatWAT } from '@/lib/format'; interface Exercise { id: string; @@ -161,8 +163,81 @@ export default async function InstructorDashboard() { + + {/* Past Cohorts History */} + ); } + +async function PastCohorts() { + const history = await getAllHistoryResults().catch(() => []); + if (history.length === 0) return null; + + // Group by cohort + const cohorts = new Map>(); + for (const h of history) { + if (!cohorts.has(h.cohort)) cohorts.set(h.cohort, []); + cohorts.get(h.cohort)!.push(h); + } + + return ( +
+
+ Past Recoding +
+ {Array.from(cohorts.entries()).map(([cohort, rows]) => { + const passed = rows.filter((r) => r.passed === true).length; + const failed = rows.filter((r) => r.passed === false).length; + return ( +
+
+ {cohort} +
+ {passed} passed + {failed} failed + {rows.length} total +
+
+
+ + + + + + + + + + + + + {rows.map((r, i) => ( + + + + + + + + + ))} + +
ParticipantExerciseScoreResultQuestionsCompleted
{r.username}{r.exercise_title} + {r.score !== null ? `${Number(r.score).toFixed(1)}%` : '—'} + + {r.passed === true + ? Pass + : r.passed === false + ? Fail + : } + {r.final_count}/{r.question_count}{r.closed_at ? formatWAT(r.closed_at) : '—'}
+
+
+ ); + })} +
+ ); +} diff --git a/app/participant/page.tsx b/app/participant/page.tsx index 05f6c2b..6bb15e2 100644 --- a/app/participant/page.tsx +++ b/app/participant/page.tsx @@ -5,6 +5,8 @@ import { sql } from '@/lib/db'; import Link from 'next/link'; import Navbar from '@/app/components/Navbar'; import { InboxIcon } from 'lucide-react'; +import { getParticipantHistory } from '@/lib/history-db'; +import { formatWAT } from '@/lib/format'; export const dynamic = 'force-dynamic'; @@ -49,8 +51,7 @@ export default async function ExerciseCataloguePage() { console.error('[participant/page] Failed to load exercises:', err); } - if (!fetchError && exercises.length > 0) { - try { + if (!fetchError && exercises.length > 0) { try { const ids = exercises.map((e) => e.id); const sessions = await sql` SELECT exercise_id, closed_at, score, passed, @@ -97,6 +98,9 @@ export default async function ExerciseCataloguePage() { } } + // Fetch history from past exam DBs + const history = await getParticipantHistory(session.user.name ?? '').catch(() => []); + return (
@@ -178,6 +182,38 @@ export default async function ExerciseCataloguePage() { ); })}
+ + {/* Past Recoding History */} + {history.length > 0 && ( +
+
+

Past Recoding

+

Your results from previous recoding sessions.

+
+
+ {history.map((h, i) => ( +
+
+
{h.exercise_title}
+
+ {h.cohort} + {h.closed_at && {formatWAT(h.closed_at)}} + {h.final_count}/{h.question_count} questions submitted +
+
+
+ {h.score !== null && ( + + {Number(h.score).toFixed(1)}%{h.passed === true ? ' ✓ Pass' : h.passed === false ? ' ✗ Fail' : ''} + + )} + Completed +
+
+ ))} +
+
+ )} diff --git a/lib/history-db.ts b/lib/history-db.ts new file mode 100644 index 0000000..26b67fe --- /dev/null +++ b/lib/history-db.ts @@ -0,0 +1,160 @@ +/** + * History database connections — read-only connections to past exam DBs. + * Configure via env vars: HISTORY_DB_1_URL, HISTORY_DB_1_LABEL, etc. + */ + +import { neon } from '@neondatabase/serverless'; + +export interface HistoryDb { + index: number; + label: string; + sql: ReturnType; +} + +let _cache: HistoryDb[] | null = null; + +export function getHistoryDbs(): HistoryDb[] { + if (_cache) return _cache; + + const dbs: HistoryDb[] = []; + let i = 1; + + while (true) { + const url = process.env[`HISTORY_DB_${i}_URL`]; + if (!url) break; + + const label = process.env[`HISTORY_DB_${i}_LABEL`] ?? `Cohort ${i}`; + dbs.push({ index: i, label, sql: neon(url) }); + i++; + } + + _cache = dbs; + return dbs; +} + +export interface HistorySession { + cohort: string; + cohortIndex: number; + exercise_title: string; + exercise_slug: string; + score: number | null; + passed: boolean | null; + closed_at: string | null; + question_count: number; + final_count: number; +} + +/** + * Fetch past session results for a participant across all history DBs. + * Matches by username since user IDs may differ across DBs. + */ +export async function getParticipantHistory(username: string): Promise { + const dbs = getHistoryDbs(); + if (dbs.length === 0) return []; + + const results: HistorySession[] = []; + + await Promise.all( + dbs.map(async (db) => { + try { + const rows = await db.sql` + SELECT + e.title AS exercise_title, + e.slug AS exercise_slug, + e.question_count, + s.score, + s.passed, + s.closed_at, + (SELECT COUNT(*)::int FROM submissions sub + WHERE sub.session_id = s.id AND sub.is_final = true) AS final_count + FROM sessions s + JOIN exercises e ON e.id = s.exercise_id + JOIN users u ON u.id = s.user_id + WHERE u.username = ${username} + AND s.closed_at IS NOT NULL + ORDER BY s.closed_at DESC + `; + + for (const row of rows) { + results.push({ + cohort: db.label, + cohortIndex: db.index, + exercise_title: row.exercise_title as string, + exercise_slug: row.exercise_slug as string, + score: row.score as number | null, + passed: row.passed as boolean | null, + closed_at: row.closed_at as string | null, + question_count: row.question_count as number, + final_count: row.final_count as number, + }); + } + } catch (err) { + console.error(`[history-db] Failed to query cohort "${db.label}":`, err); + } + }) + ); + + // Sort by closed_at descending across all cohorts + return results.sort((a, b) => { + if (!a.closed_at) return 1; + if (!b.closed_at) return -1; + return new Date(b.closed_at).getTime() - new Date(a.closed_at).getTime(); + }); +} + +/** + * Fetch all past results across all history DBs for instructor view. + */ +export async function getAllHistoryResults(): Promise> { + const dbs = getHistoryDbs(); + if (dbs.length === 0) return []; + + const results: Array = []; + + await Promise.all( + dbs.map(async (db) => { + try { + const rows = await db.sql` + SELECT + u.username, + e.title AS exercise_title, + e.slug AS exercise_slug, + e.question_count, + s.score, + s.passed, + s.closed_at, + (SELECT COUNT(*)::int FROM submissions sub + WHERE sub.session_id = s.id AND sub.is_final = true) AS final_count + FROM sessions s + JOIN exercises e ON e.id = s.exercise_id + JOIN users u ON u.id = s.user_id + WHERE s.closed_at IS NOT NULL + ORDER BY u.username, s.closed_at DESC + `; + + for (const row of rows) { + results.push({ + username: row.username as string, + cohort: db.label, + cohortIndex: db.index, + exercise_title: row.exercise_title as string, + exercise_slug: row.exercise_slug as string, + score: row.score as number | null, + passed: row.passed as boolean | null, + closed_at: row.closed_at as string | null, + question_count: row.question_count as number, + final_count: row.final_count as number, + }); + } + } catch (err) { + console.error(`[history-db] Failed to query cohort "${db.label}":`, err); + } + }) + ); + + return results.sort((a, b) => { + if (!a.closed_at) return 1; + if (!b.closed_at) return -1; + return new Date(b.closed_at).getTime() - new Date(a.closed_at).getTime(); + }); +} diff --git a/migrations/reset_keep_users.sql b/migrations/reset_keep_users.sql new file mode 100644 index 0000000..1d6c158 --- /dev/null +++ b/migrations/reset_keep_users.sql @@ -0,0 +1,20 @@ +-- ============================================================ +-- Reset script — wipes all session/exercise data, keeps users +-- Safe to run between exam cohorts. +-- Run with: psql $DATABASE_URL -f migrations/reset_keep_users.sql +-- ============================================================ + +-- Delete in dependency order +TRUNCATE TABLE + audit_log, + autosave_history, + edit_events, + paste_events, + focus_events, + submissions, + sessions, + exercise_assignments, + questions, + exercises, + feedback +RESTART IDENTITY CASCADE; diff --git a/package.json b/package.json index 89ce372..6493f44 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "next lint", "test": "vitest run", "migrate": "psql $DATABASE_URL -f migrations/run_all.sql", + "db:reset": "psql $DATABASE_URL -f migrations/reset_keep_users.sql", "test:watch": "vitest", "migrate:fresh": "tsx scripts/migrate.ts fresh && tsx scripts/seed.ts && tsx scripts/import-questions.ts", "seed": "tsx scripts/seed.ts", From 678c0121afd5dc076f85501d7e9943a36eb81645 Mon Sep 17 00:00:00 2001 From: jvcByte Date: Fri, 15 May 2026 16:04:56 +0100 Subject: [PATCH 2/2] feat: past recoding history, db reset script, login error messages, migration fixes --- lib/history-db.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/history-db.ts b/lib/history-db.ts index 26b67fe..375cccf 100644 --- a/lib/history-db.ts +++ b/lib/history-db.ts @@ -73,7 +73,7 @@ export async function getParticipantHistory(username: string): Promise[]; for (const row of rows) { results.push({ @@ -130,7 +130,7 @@ export async function getAllHistoryResults(): Promise[]; for (const row of rows) { results.push({