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
46 changes: 46 additions & 0 deletions app/components/Pagination.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0.6rem 1rem', borderTop: '1px solid var(--border)', fontSize: 12, color: 'var(--text3)' }}>
<span>{from}–{to} of {total}</span>
<div style={{ display: 'flex', gap: '0.25rem' }}>
<button className="btn btn-ghost btn-sm" disabled={page === 1} onClick={() => onPage(1)}>«</button>
<button className="btn btn-ghost btn-sm" disabled={page === 1} onClick={() => onPage(page - 1)}>‹</button>
{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 === '…'
? <span key={`ellipsis-${i}`} style={{ padding: '0 0.25rem', color: 'var(--text3)' }}>…</span>
: <button
key={p}
className={`btn btn-sm ${p === page ? 'btn-primary' : 'btn-ghost'}`}
onClick={() => onPage(p as number)}
style={{ minWidth: 28 }}
>
{p}
</button>
)}
<button className="btn btn-ghost btn-sm" disabled={page === totalPages} onClick={() => onPage(page + 1)}>›</button>
<button className="btn btn-ghost btn-sm" disabled={page === totalPages} onClick={() => onPage(totalPages)}>»</button>
</div>
</div>
);
}
14 changes: 12 additions & 2 deletions app/instructor/ExercisesTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 (
<div className="card" style={{ marginBottom: '1.5rem' }}>
<div className="card-header">
<span className="card-title">Exercises</span>
<SearchInput value={search} onChange={setSearch} placeholder="Search exercises…" />
<SearchInput value={search} onChange={handleSearch} placeholder="Search exercises…" />
</div>
{exercises.length === 0 ? (
<p style={{ color: 'var(--text3)', fontSize: 13 }}>No exercises yet. Create one above.</p>
Expand All @@ -44,7 +53,7 @@ export default function ExercisesTable({ exercises }: { exercises: Exercise[] })
</tr>
</thead>
<tbody>
{filtered.map((ex) => (
{paged.map((ex) => (
<tr key={ex.id}>
<td style={{ fontWeight: 600 }}>{ex.title}</td>
<td>
Expand All @@ -64,6 +73,7 @@ export default function ExercisesTable({ exercises }: { exercises: Exercise[] })
))}
</tbody>
</table>
<Pagination page={page} totalPages={totalPages} total={filtered.length} pageSize={PAGE_SIZE} onPage={setPage} />
</div>
)}
</div>
Expand Down
179 changes: 179 additions & 0 deletions app/instructor/PastRecodingTable.tsx
Original file line number Diff line number Diff line change
@@ -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<Set<string>>(new Set());

// Group sessions by username
const userMap = new Map<string, Row[]>();
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 (
<div className="card" style={{ marginBottom: '1rem' }}>
<div className="card-header">
<span className="card-title">{cohort}</span>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<SearchInput value={search} onChange={(v) => { setSearch(v); setPage(1); }} placeholder="Search participant…" />
<span className="badge badge-green">{passedCount} passed</span>
<span className="badge badge-red">{failedCount} failed</span>
<span className="badge badge-gray">{users.length} participants</span>
</div>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th style={{ width: 24 }} />
<th>Participant</th>
<th>Exercise</th>
<th>Score</th>
<th>Result</th>
<th>Questions</th>
<th>Completed</th>
</tr>
</thead>
<tbody>
{paged.length === 0 ? (
<tr><td colSpan={7} style={{ textAlign: 'center', color: 'var(--text3)', padding: '2rem' }}>
{search ? `No results for "${search}"` : 'No data'}
</td></tr>
) : paged.map((u) => {
const isExpanded = expanded.has(u.username);
return (
<>
{/* User summary row */}
<tr
key={u.username}
style={{ cursor: 'pointer' }}
onClick={() => toggle(u.username)}
>
<td style={{ color: 'var(--text3)', paddingRight: 0 }}>
{isExpanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
</td>
<td style={{ fontWeight: 600 }}>{u.username}</td>
<td style={{ color: 'var(--text3)', fontSize: 12 }}>
{u.sessions.map((s) => s.exercise_title).join(', ')}
</td>
<td style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600,
color: u.passed === false ? 'var(--red)' : u.passed === true ? 'var(--green)' : 'var(--text)' }}>
{u.score !== null ? `${Number(u.score).toFixed(1)}%` : '—'}
</td>
<td>
{u.passed === true
? <span className="badge badge-green">Pass</span>
: u.passed === false
? <span className="badge badge-red">Fail</span>
: <span className="badge badge-gray">—</span>}
</td>
<td style={{ color: 'var(--text2)' }}>{u.totalFinal}/{u.totalQuestions}</td>
<td style={{ color: 'var(--text3)', fontSize: 12 }}>
{u.lastActivity ? formatWAT(u.lastActivity) : '—'}
</td>
</tr>

{/* Expanded: per-question submissions */}
{isExpanded && u.sessions.map((session) =>
(session.submissions ?? []).map((sub) => (
<tr key={`${u.username}-${session.exercise_slug}-${sub.question_index}`}
style={{ background: 'var(--bg3)' }}>
<td />
<td style={{ color: 'var(--text3)', fontSize: 12, paddingLeft: '1.5rem' }}>
Q{sub.question_index + 1}
</td>
<td style={{ fontSize: 12, color: 'var(--text2)' }}>{session.exercise_title}</td>
<td>
{sub.tests_passed === true
? <span className="badge badge-green" style={{ fontSize: 10 }}>Tests ✓</span>
: sub.tests_passed === false
? <span className="badge badge-red" style={{ fontSize: 10 }}>Tests ✗</span>
: null}
</td>
<td>
<span className={`badge ${sub.is_final ? 'badge-green' : 'badge-gray'}`} style={{ fontSize: 10 }}>
{sub.is_final ? 'Final' : 'Draft'}
</span>
</td>
<td style={{ color: 'var(--text3)', fontSize: 11 }}>
{sub.response_text
? <span style={{ fontFamily: 'monospace', fontSize: 11, color: 'var(--text2)' }}>
{sub.response_text.slice(0, 60)}{sub.response_text.length > 60 ? '…' : ''}
</span>
: <span style={{ color: 'var(--text3)' }}>—</span>}
</td>
<td style={{ color: 'var(--text3)', fontSize: 11 }}>
{formatWAT(sub.submitted_at)}
</td>
</tr>
))
)}
</>
);
})}
</tbody>
</table>
<Pagination page={page} totalPages={totalPages} total={filtered.length} pageSize={PAGE_SIZE} onPage={setPage} />
</div>
</div>
);
}
16 changes: 13 additions & 3 deletions app/instructor/exercises/[id]/submissions/SubmissionsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Set<string>>(new Set());
const [overriding, setOverriding] = useState<string | null>(null);
const [localOverride, setLocalOverride] = useState<Record<string, boolean | null>>({});
const [page, setPage] = useState(1);

async function handleOverride(sessionId: string, value: boolean | null) {
setOverriding(sessionId);
Expand All @@ -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);
Expand All @@ -51,10 +60,10 @@ export default function SubmissionsTable({ participants }: { participants: Parti
return (
<>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap' }}>
<SearchInput value={search} onChange={setSearch} placeholder="Search by participant…" />
<SearchInput value={search} onChange={handleSearch} placeholder="Search by participant…" />
<button
className={`btn btn-sm ${filterFlagged ? 'btn-danger' : 'btn-ghost'}`}
onClick={() => setFilterFlagged((f) => !f)}
onClick={() => handleFilter(!filterFlagged)}
>
<Flag size={11} /> {filterFlagged ? `Flagged (${flaggedCount})` : 'Show flagged only'}
</button>
Expand Down Expand Up @@ -82,7 +91,7 @@ export default function SubmissionsTable({ participants }: { participants: Parti
</tr>
</thead>
<tbody>
{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
Expand Down Expand Up @@ -204,6 +213,7 @@ export default function SubmissionsTable({ participants }: { participants: Parti
})}
</tbody>
</table>
<Pagination page={page} totalPages={totalPages} total={filtered.length} pageSize={PAGE_SIZE} onPage={setPage} />
</div>
)}
</>
Expand Down
Loading
Loading