Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions app/instructor/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -161,8 +163,81 @@ export default async function InstructorDashboard() {
</div>
<LiveMonitor />
</div>

{/* Past Cohorts History */}
<PastCohorts />
</div>
</main>
</div>
);
}

async function PastCohorts() {
const history = await getAllHistoryResults().catch(() => []);
if (history.length === 0) return null;

// Group by cohort
const cohorts = new Map<string, Array<HistorySession & { username: string }>>();
for (const h of history) {
if (!cohorts.has(h.cohort)) cohorts.set(h.cohort, []);
cohorts.get(h.cohort)!.push(h);
}

return (
<div style={{ marginTop: '2rem' }}>
<div className="section-header">
<span className="section-title">Past Recoding</span>
</div>
{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 (
<div key={cohort} className="card" style={{ marginBottom: '1rem' }}>
<div className="card-header">
<span className="card-title">{cohort}</span>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span className="badge badge-green">{passed} passed</span>
<span className="badge badge-red">{failed} failed</span>
<span className="badge badge-gray">{rows.length} total</span>
</div>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Participant</th>
<th>Exercise</th>
<th>Score</th>
<th>Result</th>
<th>Questions</th>
<th>Completed</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}>
<td style={{ fontWeight: 600 }}>{r.username}</td>
<td>{r.exercise_title}</td>
<td style={{ fontVariantNumeric: 'tabular-nums' }}>
{r.score !== null ? `${Number(r.score).toFixed(1)}%` : '—'}
</td>
<td>
{r.passed === true
? <span className="badge badge-green">Pass</span>
: r.passed === false
? <span className="badge badge-red">Fail</span>
: <span className="badge badge-gray">—</span>}
</td>
<td style={{ color: 'var(--text2)' }}>{r.final_count}/{r.question_count}</td>
<td style={{ color: 'var(--text3)' }}>{r.closed_at ? formatWAT(r.closed_at) : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
})}
</div>
);
}
40 changes: 38 additions & 2 deletions app/participant/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -97,6 +98,9 @@ export default async function ExerciseCataloguePage() {
}
}

// Fetch history from past exam DBs
const history = await getParticipantHistory(session.user.name ?? '').catch(() => []);

return (
<div className="page">
<Navbar username={session.user.name ?? undefined} role="participant" />
Expand Down Expand Up @@ -178,6 +182,38 @@ export default async function ExerciseCataloguePage() {
);
})}
</div>

{/* Past Recoding History */}
{history.length > 0 && (
<div style={{ marginTop: '2rem' }}>
<div className="page-header" style={{ marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1.1rem', fontWeight: 700, color: 'var(--text)' }}>Past Recoding</h2>
<p className="page-sub">Your results from previous recoding sessions.</p>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{history.map((h, i) => (
<div key={i} className="exercise-card" style={{ cursor: 'default' }}>
<div className="exercise-card-info">
<div className="exercise-card-title">{h.exercise_title}</div>
<div className="exercise-card-meta" style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<span>{h.cohort}</span>
{h.closed_at && <span>{formatWAT(h.closed_at)}</span>}
<span>{h.final_count}/{h.question_count} questions submitted</span>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
{h.score !== null && (
<span className={`badge ${h.passed === false ? 'badge-red' : h.passed === true ? 'badge-green' : 'badge-gray'}`} style={{ fontSize: 11 }}>
{Number(h.score).toFixed(1)}%{h.passed === true ? ' ✓ Pass' : h.passed === false ? ' ✗ Fail' : ''}
</span>
)}
<span className="badge badge-gray" style={{ fontSize: 11 }}>Completed</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
</main>
</div>
Expand Down
160 changes: 160 additions & 0 deletions lib/history-db.ts
Original file line number Diff line number Diff line change
@@ -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<typeof neon>;
}

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<HistorySession[]> {
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
` as Record<string, unknown>[];

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<Array<HistorySession & { username: string }>> {
const dbs = getHistoryDbs();
if (dbs.length === 0) return [];

const results: Array<HistorySession & { username: string }> = [];

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
` as Record<string, unknown>[];

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();
});
}
20 changes: 20 additions & 0 deletions migrations/reset_keep_users.sql
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading