diff --git a/app/components/Pagination.tsx b/app/components/Pagination.tsx new file mode 100644 index 0000000..5d50da2 --- /dev/null +++ b/app/components/Pagination.tsx @@ -0,0 +1,46 @@ +'use client'; + +interface Props { + page: number; + totalPages: number; + total: number; + pageSize: number; + onPage: (p: number) => void; +} + +export default function Pagination({ page, totalPages, total, pageSize, onPage }: Props) { + if (totalPages <= 1) return null; + const from = (page - 1) * pageSize + 1; + const to = Math.min(page * pageSize, total); + + return ( +
+ {from}–{to} of {total} +
+ + + {Array.from({ length: totalPages }, (_, i) => i + 1) + .filter((p) => p === 1 || p === totalPages || Math.abs(p - page) <= 1) + .reduce<(number | '…')[]>((acc, p, i, arr) => { + if (i > 0 && (p as number) - (arr[i - 1] as number) > 1) acc.push('…'); + acc.push(p); + return acc; + }, []) + .map((p, i) => + p === '…' + ? + : + )} + + +
+
+ ); +} diff --git a/app/instructor/ExercisesTable.tsx b/app/instructor/ExercisesTable.tsx index 5058b86..0c1cecf 100644 --- a/app/instructor/ExercisesTable.tsx +++ b/app/instructor/ExercisesTable.tsx @@ -3,6 +3,9 @@ import { useState } from 'react'; import Link from 'next/link'; import SearchInput from '@/app/components/SearchInput'; +import Pagination from '@/app/components/Pagination'; + +const PAGE_SIZE = 20; interface Exercise { id: string; @@ -15,17 +18,23 @@ interface Exercise { export default function ExercisesTable({ exercises }: { exercises: Exercise[] }) { const [search, setSearch] = useState(''); + const [page, setPage] = useState(1); const filtered = exercises.filter((ex) => ex.title.toLowerCase().includes(search.toLowerCase()) || ex.slug.toLowerCase().includes(search.toLowerCase()) ); + const totalPages = Math.ceil(filtered.length / PAGE_SIZE); + const paged = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + + function handleSearch(v: string) { setSearch(v); setPage(1); } + return (
Exercises - +
{exercises.length === 0 ? (

No exercises yet. Create one above.

@@ -44,7 +53,7 @@ export default function ExercisesTable({ exercises }: { exercises: Exercise[] }) - {filtered.map((ex) => ( + {paged.map((ex) => ( {ex.title} @@ -64,6 +73,7 @@ export default function ExercisesTable({ exercises }: { exercises: Exercise[] }) ))} +
)} diff --git a/app/instructor/PastRecodingTable.tsx b/app/instructor/PastRecodingTable.tsx new file mode 100644 index 0000000..14aa076 --- /dev/null +++ b/app/instructor/PastRecodingTable.tsx @@ -0,0 +1,179 @@ +'use client'; + +import { useState } from 'react'; +import SearchInput from '@/app/components/SearchInput'; +import Pagination from '@/app/components/Pagination'; +import { formatWAT } from '@/lib/format'; +import { ChevronDown, ChevronRight, Flag } from 'lucide-react'; +import type { HistorySession } from '@/lib/history-db'; + +const PAGE_SIZE = 25; + +type Row = HistorySession & { username: string }; + +interface UserSummary { + username: string; + sessions: Row[]; + totalFinal: number; + totalQuestions: number; + passed: boolean | null; + score: number | null; + lastActivity: string | null; +} + +export default function PastRecodingTable({ rows, cohort }: { rows: Row[]; cohort: string }) { + const [search, setSearch] = useState(''); + const [page, setPage] = useState(1); + const [expanded, setExpanded] = useState>(new Set()); + + // Group sessions by username + const userMap = new Map(); + for (const r of rows) { + if (!userMap.has(r.username)) userMap.set(r.username, []); + userMap.get(r.username)!.push(r); + } + + const users: UserSummary[] = Array.from(userMap.entries()).map(([username, sessions]) => { + const totalFinal = sessions.reduce((s, r) => s + r.final_count, 0); + const totalQuestions = sessions.reduce((s, r) => s + r.question_count, 0); + // Use the most recent session's pass/score + const latest = sessions.sort((a, b) => + new Date(b.closed_at ?? 0).getTime() - new Date(a.closed_at ?? 0).getTime() + )[0]; + return { + username, + sessions, + totalFinal, + totalQuestions, + passed: latest.passed, + score: latest.score, + lastActivity: latest.closed_at, + }; + }).sort((a, b) => a.username.localeCompare(b.username)); + + const filtered = users.filter((u) => + u.username.toLowerCase().includes(search.toLowerCase()) + ); + + const totalPages = Math.ceil(filtered.length / PAGE_SIZE); + const paged = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + + const passedCount = users.filter((u) => u.passed === true).length; + const failedCount = users.filter((u) => u.passed === false).length; + + function toggle(username: string) { + setExpanded((prev) => { + const next = new Set(prev); + next.has(username) ? next.delete(username) : next.add(username); + return next; + }); + } + + return ( +
+
+ {cohort} +
+ { setSearch(v); setPage(1); }} placeholder="Search participant…" /> + {passedCount} passed + {failedCount} failed + {users.length} participants +
+
+
+ + + + + + + + + + + + + {paged.length === 0 ? ( + + ) : paged.map((u) => { + const isExpanded = expanded.has(u.username); + return ( + <> + {/* User summary row */} + toggle(u.username)} + > + + + + + + + + + + {/* Expanded: per-question submissions */} + {isExpanded && u.sessions.map((session) => + (session.submissions ?? []).map((sub) => ( + + + + + + + + + )) + )} + + ); + })} + +
+ ParticipantExerciseScoreResultQuestionsCompleted
+ {search ? `No results for "${search}"` : 'No data'} +
+ {isExpanded ? : } + {u.username} + {u.sessions.map((s) => s.exercise_title).join(', ')} + + {u.score !== null ? `${Number(u.score).toFixed(1)}%` : '—'} + + {u.passed === true + ? Pass + : u.passed === false + ? Fail + : } + {u.totalFinal}/{u.totalQuestions} + {u.lastActivity ? formatWAT(u.lastActivity) : '—'} +
+ + Q{sub.question_index + 1} + {session.exercise_title} + {sub.tests_passed === true + ? Tests ✓ + : sub.tests_passed === false + ? Tests ✗ + : null} + + + {sub.is_final ? 'Final' : 'Draft'} + + + {sub.response_text + ? + {sub.response_text.slice(0, 60)}{sub.response_text.length > 60 ? '…' : ''} + + : } + + {formatWAT(sub.submitted_at)} +
+ +
+
+ ); +} diff --git a/app/instructor/exercises/[id]/submissions/SubmissionsTable.tsx b/app/instructor/exercises/[id]/submissions/SubmissionsTable.tsx index d89c86c..ccf78c2 100644 --- a/app/instructor/exercises/[id]/submissions/SubmissionsTable.tsx +++ b/app/instructor/exercises/[id]/submissions/SubmissionsTable.tsx @@ -4,15 +4,19 @@ import { useState } from 'react'; import Link from 'next/link'; import { Flag, ChevronDown, ChevronRight, ShieldCheck, ShieldX, RotateCcw } from 'lucide-react'; import SearchInput from '@/app/components/SearchInput'; +import Pagination from '@/app/components/Pagination'; import { toast } from 'sonner'; import type { ParticipantRow } from './page'; +const PAGE_SIZE = 25; + export default function SubmissionsTable({ participants }: { participants: ParticipantRow[] }) { const [search, setSearch] = useState(''); const [filterFlagged, setFilterFlagged] = useState(false); const [expanded, setExpanded] = useState>(new Set()); const [overriding, setOverriding] = useState(null); const [localOverride, setLocalOverride] = useState>({}); + const [page, setPage] = useState(1); async function handleOverride(sessionId: string, value: boolean | null) { setOverriding(sessionId); @@ -38,8 +42,13 @@ export default function SubmissionsTable({ participants }: { participants: Parti return matchSearch && matchFlag; }); + const totalPages = Math.ceil(filtered.length / PAGE_SIZE); + const paged = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); const flaggedCount = participants.filter((p) => p.is_flagged).length; + function handleSearch(v: string) { setSearch(v); setPage(1); } + function handleFilter(v: boolean) { setFilterFlagged(v); setPage(1); } + function toggleExpand(sessionId: string) { setExpanded((prev) => { const next = new Set(prev); @@ -51,10 +60,10 @@ export default function SubmissionsTable({ participants }: { participants: Parti return ( <>
- + @@ -82,7 +91,7 @@ export default function SubmissionsTable({ participants }: { participants: Parti - {filtered.map((p) => { + {paged.map((p) => { const isExpanded = expanded.has(p.session_id); // local override takes precedence over DB value const effectivePassed = p.session_id in localOverride @@ -204,6 +213,7 @@ export default function SubmissionsTable({ participants }: { participants: Parti })} +
)} diff --git a/app/instructor/page.tsx b/app/instructor/page.tsx index 6b874b3..e51eb9a 100644 --- a/app/instructor/page.tsx +++ b/app/instructor/page.tsx @@ -10,6 +10,7 @@ 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'; +import PastRecodingTable from './PastRecodingTable'; interface Exercise { id: string; @@ -188,56 +189,9 @@ async function PastCohorts() {
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) : '—'}
-
-
- ); - })} + {Array.from(cohorts.entries()).map(([cohort, rows]) => ( + + ))} ); } diff --git a/app/instructor/users/UserManager.tsx b/app/instructor/users/UserManager.tsx index d625a17..0c4c682 100644 --- a/app/instructor/users/UserManager.tsx +++ b/app/instructor/users/UserManager.tsx @@ -5,9 +5,11 @@ import { useRouter } from 'next/navigation'; import { toast } from 'sonner'; import { Plus, X, KeyRound, Trash2, AlertTriangle } from 'lucide-react'; import SearchInput from '@/app/components/SearchInput'; - +import Pagination from '@/app/components/Pagination'; import { formatDateWAT } from '@/lib/format'; +const PAGE_SIZE = 25; + interface User { id: string; username: string; role: string; created_at: string; } interface Props { initialUsers: User[]; currentUserId: string; } @@ -31,6 +33,11 @@ export default function UserManager({ initialUsers, currentUserId }: Props) { u.role.toLowerCase().includes(search.toLowerCase()) ); + const [page, setPage] = useState(1); + const totalPages = Math.ceil(filtered.length / PAGE_SIZE); + const paged = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + function handleSearch(v: string) { setSearch(v); setPage(1); } + async function handleCreate() { if (!newUsername.trim()) { toast.error('Username is required'); return; } if (newPassword.length < 8) { toast.error('Password must be at least 8 characters'); return; } @@ -125,7 +132,7 @@ export default function UserManager({ initialUsers, currentUserId }: Props) {
All Users
- + {filtered.length}
@@ -137,7 +144,7 @@ export default function UserManager({ initialUsers, currentUserId }: Props) { {filtered.length === 0 ? ( No users match "{search}" - ) : filtered.map((user) => ( + ) : paged.map((user) => ( <> {user.username} @@ -194,6 +201,7 @@ export default function UserManager({ initialUsers, currentUserId }: Props) { ))} + diff --git a/lib/history-db.ts b/lib/history-db.ts index 375cccf..53b7143 100644 --- a/lib/history-db.ts +++ b/lib/history-db.ts @@ -32,6 +32,14 @@ export function getHistoryDbs(): HistoryDb[] { return dbs; } +export interface HistorySubmission { + question_index: number; + response_text: string; + is_final: boolean; + tests_passed: boolean | null; + submitted_at: string; +} + export interface HistorySession { cohort: string; cohortIndex: number; @@ -42,6 +50,7 @@ export interface HistorySession { closed_at: string | null; question_count: number; final_count: number; + submissions?: HistorySubmission[]; } /** @@ -120,6 +129,7 @@ export async function getAllHistoryResults(): Promise[]; for (const row of rows) { + // Fetch submissions for this session + let submissions: HistorySubmission[] = []; + try { + const subRows = await db.sql` + SELECT question_index, response_text, is_final, tests_passed, submitted_at + FROM submissions + WHERE session_id = ${row.session_id as string} + ORDER BY question_index + ` as Record[]; + submissions = subRows.map((s) => ({ + question_index: s.question_index as number, + response_text: s.response_text as string, + is_final: s.is_final as boolean, + tests_passed: s.tests_passed as boolean | null, + submitted_at: s.submitted_at as string, + })); + } catch { /* ignore */ } + results.push({ username: row.username as string, cohort: db.label, @@ -144,6 +172,7 @@ export async function getAllHistoryResults(): Promise