From fdbc19cf0de86575f41bda93b6560750d7915d69 Mon Sep 17 00:00:00 2001 From: kcar Date: Sun, 7 Jun 2026 00:33:06 +0100 Subject: [PATCH 01/14] feat(exam): version templates on dashboard --- src/pages/exam/ExamDashboardPage.tsx | 291 +++++++++++++++++++++------ src/services/exam/examRepository.ts | 117 +++++++++++ src/types/exam.types.ts | 20 +- 3 files changed, 364 insertions(+), 64 deletions(-) diff --git a/src/pages/exam/ExamDashboardPage.tsx b/src/pages/exam/ExamDashboardPage.tsx index 500c45f..8c28b41 100644 --- a/src/pages/exam/ExamDashboardPage.tsx +++ b/src/pages/exam/ExamDashboardPage.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Alert, @@ -22,6 +22,8 @@ import { import AddIcon from '@mui/icons-material/Add'; import ArchiveIcon from '@mui/icons-material/Archive'; import AssignmentIcon from '@mui/icons-material/Assignment'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import EditIcon from '@mui/icons-material/Edit'; import { useAuth } from '../../contexts/AuthContext'; import { examRepository } from '../../services/exam/examRepository'; @@ -34,6 +36,51 @@ const STATUS_COLOR: Record = archived: 'default', }; +const VERSION_SEPARATOR = ' · '; +const VERSION_RE = /(?:^|\s)[vV](\d+(?:\.\d+)*)$/; + +type DialogMode = 'create' | 'edit' | 'duplicate'; + +type TemplateDialogState = { + mode: DialogMode; + template?: ExamTemplate; +} | null; + +function splitTemplateTitle(title: string): { name: string; version: string } { + const parts = title.split(VERSION_SEPARATOR); + const possibleVersion = parts[parts.length - 1]?.trim() ?? ''; + if (parts.length > 1 && VERSION_RE.test(possibleVersion)) { + return { name: parts.slice(0, -1).join(VERSION_SEPARATOR).trim(), version: possibleVersion }; + } + return { name: title, version: 'v1' }; +} + +function composeTemplateTitle(name: string, version: string): string { + const cleanName = name.trim(); + const cleanVersion = version.trim(); + return cleanVersion ? `${cleanName}${VERSION_SEPARATOR}${cleanVersion}` : cleanName; +} + +function nextVersionLabel(version: string): string { + const match = version.trim().match(VERSION_RE); + if (!match) return 'v2'; + const segments = match[1].split('.'); + const last = Number(segments[segments.length - 1]); + segments[segments.length - 1] = Number.isFinite(last) ? String(last + 1) : '2'; + return `v${segments.join('.')}`; +} + +function paperKey(t: ExamTemplate): string { + return t.exam_id ?? t.source_file_id ?? t.exam_code ?? t.subject ?? 'custom-paper'; +} + +function paperLabel(t: ExamTemplate): string { + if (t.exam_code) return t.exam_code; + if (t.subject) return t.subject; + if (t.source_file_id) return 'Uploaded paper'; + return 'Custom paper'; +} + const ExamDashboardPage: React.FC = () => { const navigate = useNavigate(); const { bootstrapData } = useAuth(); @@ -43,8 +90,9 @@ const ExamDashboardPage: React.FC = () => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [createOpen, setCreateOpen] = useState(false); - const [title, setTitle] = useState(''); + const [dialog, setDialog] = useState(null); + const [templateName, setTemplateName] = useState(''); + const [version, setVersion] = useState('v1'); const [subject, setSubject] = useState(''); const [saving, setSaving] = useState(false); @@ -66,33 +114,99 @@ const ExamDashboardPage: React.FC = () => { void load(); }, [load, instituteId]); - const handleCreate = async () => { - if (!title.trim()) return; + const groupedTemplates = useMemo(() => { + const groups = new Map(); + templates.forEach((template) => { + const key = paperKey(template); + const existing = groups.get(key); + if (existing) { + existing.templates.push(template); + } else { + groups.set(key, { label: paperLabel(template), templates: [template] }); + } + }); + return Array.from(groups.entries()).map(([key, group]) => ({ key, ...group })); + }, [templates]); + + const openCreate = () => { + setTemplateName('New template'); + setVersion('v1'); + setSubject(''); + setDialog({ mode: 'create' }); + }; + + const openEdit = (template: ExamTemplate, ev: React.MouseEvent) => { + ev.stopPropagation(); + const parsed = splitTemplateTitle(template.title); + setTemplateName(parsed.name); + setVersion(parsed.version); + setSubject(template.subject ?? ''); + setDialog({ mode: 'edit', template }); + }; + + const openDuplicate = (template: ExamTemplate, ev: React.MouseEvent) => { + ev.stopPropagation(); + const parsed = splitTemplateTitle(template.title); + setTemplateName(parsed.name); + setVersion(nextVersionLabel(parsed.version)); + setSubject(template.subject ?? ''); + setDialog({ mode: 'duplicate', template }); + }; + + const closeDialog = () => { + if (!saving) setDialog(null); + }; + + const handleSaveDialog = async () => { + if (!dialog || !templateName.trim()) return; + const title = composeTemplateTitle(templateName, version); setSaving(true); try { - const created = await examRepository.createTemplate({ - title: title.trim(), - subject: subject.trim() || undefined, - institute_id: instituteId, + if (dialog.mode === 'create') { + const created = await examRepository.createTemplate({ + title, + subject: subject.trim() || undefined, + institute_id: instituteId, + }); + setDialog(null); + navigate(`/exam-marker/${created.id}/setup`); + return; + } + + if (!dialog.template) return; + + if (dialog.mode === 'duplicate') { + const created = await examRepository.duplicateTemplate(dialog.template.id, title); + setTemplates((prev) => [created, ...prev]); + setDialog(null); + navigate(`/exam-marker/${created.id}/setup`); + return; + } + + const updated = await examRepository.updateTemplateMeta(dialog.template.id, { + title, + subject: subject.trim() || null, }); - setCreateOpen(false); - setTitle(''); - setSubject(''); - navigate(`/exam-marker/${created.id}/setup`); + setTemplates((prev) => prev.map((t) => (t.id === updated.id ? updated : t))); + setDialog(null); } catch (e) { const msg = e instanceof Error ? e.message : String(e); - logger.error('cc-exam-marker', 'Create template failed', { message: msg }); + logger.error('cc-exam-marker', 'Template action failed', { message: msg, mode: dialog.mode }); setError(msg); } finally { setSaving(false); } }; - const handleArchive = async (id: string, ev: React.MouseEvent) => { + const handleArchive = async (template: ExamTemplate, ev: React.MouseEvent) => { ev.stopPropagation(); + const parsed = splitTemplateTitle(template.title); + if (!window.confirm(`Archive ${parsed.name} ${parsed.version}? This hides it from the dashboard but keeps the work recoverable.`)) { + return; + } try { - await examRepository.archiveTemplate(id); - setTemplates((prev) => prev.filter((t) => t.id !== id)); + await examRepository.archiveTemplate(template.id); + setTemplates((prev) => prev.filter((t) => t.id !== template.id)); } catch (e) { const msg = e instanceof Error ? e.message : String(e); logger.error('cc-exam-marker', 'Archive failed', { message: msg }); @@ -100,6 +214,12 @@ const ExamDashboardPage: React.FC = () => { } }; + const dialogTitle = dialog?.mode === 'edit' + ? 'Rename template / edit version' + : dialog?.mode === 'duplicate' + ? 'Duplicate as new version' + : 'New exam template'; + return ( @@ -108,11 +228,11 @@ const ExamDashboardPage: React.FC = () => { Exam Marker - - Build a template for an exam paper, then run marking batches against your classes. + + Build multiple named templates for the same paper, version them as your setup changes, and archive drafts you no longer need. - @@ -132,73 +252,118 @@ const ExamDashboardPage: React.FC = () => { No exam templates yet - Create your first template to start mapping an exam paper. + Create your first named template to start mapping an exam paper. - ) : ( - - {templates.map((t) => ( - - navigate(`/exam-marker/${t.id}/setup`)} - > - - {t.title} - - handleArchive(t.id, e)} aria-label="archive template"> - - - - - {t.subject && ( - {t.subject} - )} - {t.exam_code && ( - {t.exam_code} - )} - - - - {new Date(t.updated_at).toLocaleDateString()} - - - - + + {groupedTemplates.map((group) => ( + + + {group.label} + + + + {group.templates.map((t) => { + const parsed = splitTemplateTitle(t.title); + return ( + + navigate(`/exam-marker/${t.id}/setup`)} + > + + + {parsed.name} + + + + + openEdit(t, e)} aria-label="rename template"> + + + + + openDuplicate(t, e)} aria-label="duplicate template"> + + + + + handleArchive(t, e)} aria-label="archive template"> + + + + + + {t.subject && ( + {t.subject} + )} + {t.exam_code && ( + {t.exam_code} + )} + + + + Updated {new Date(t.updated_at).toLocaleDateString()} + + + + + ); + })} + + ))} - + )} - (saving ? null : setCreateOpen(false))} fullWidth maxWidth="sm"> - New exam template + + {dialogTitle} setTitle(e.target.value)} + label="Template name" + value={templateName} + onChange={(e) => setTemplateName(e.target.value)} fullWidth autoFocus required + helperText="User-facing name. Several templates can share the same paper." /> setVersion(e.target.value)} + fullWidth + helperText="Stored in the template title until the API grows a dedicated version column." + /> + setSubject(e.target.value)} fullWidth + disabled={dialog?.mode === 'duplicate'} /> - - + diff --git a/src/services/exam/examRepository.ts b/src/services/exam/examRepository.ts index 1b5160d..e7d8d99 100644 --- a/src/services/exam/examRepository.ts +++ b/src/services/exam/examRepository.ts @@ -8,11 +8,16 @@ import axios from 'axios'; import { API_BASE } from '../../config/apiConfig'; +import { logger } from '../../debugConfig'; import { supabase } from '../../supabaseClient'; import type { CreateTemplatePayload, + ExamBoundary, + ExamQuestion, + ExamResponseArea, ExamTemplate, ExamTemplateDetail, + UpdateTemplateMetaPayload, } from '../../types/exam.types'; const EXAM_BASE = `${API_BASE}/api/exam`; @@ -25,6 +30,86 @@ async function authHeaders(): Promise> { return { Authorization: `Bearer ${session.access_token}` }; } +function newUuid(): string { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID(); + } + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = Math.floor(Math.random() * 16); + const v = c === 'x' ? r : (r % 4) + 8; + return v.toString(16); + }); +} + +function questionPayload(q: ExamQuestion, idMap?: Map) { + return { + id: idMap?.get(q.id) ?? q.id, + parent_id: q.parent_id ? (idMap?.get(q.parent_id) ?? q.parent_id) : null, + label: q.label, + order: q.order, + max_marks: q.max_marks, + answer_type: q.answer_type, + mcq_options: q.mcq_options, + mark_scheme: q.mark_scheme ?? {}, + is_container: q.is_container, + spec_ref: q.spec_ref, + bounds: q.bounds ?? null, + page: q.page ?? null, + }; +} + +function responseAreaPayload(r: ExamResponseArea, idMap?: Map, duplicate = false) { + return { + id: duplicate ? newUuid() : r.id, + question_id: idMap?.get(r.question_id) ?? r.question_id, + page: r.page, + bounds: r.bounds, + kind: r.kind, + response_form: r.response_form, + context_type: r.context_type ?? null, + source: r.source, + confirmed: r.confirmed, + confidence: r.confidence, + }; +} + +function boundaryPayload(b: ExamBoundary, idMap?: Map, duplicate = false) { + return { + id: duplicate ? newUuid() : b.id, + question_id: b.question_id ? (idMap?.get(b.question_id) ?? b.question_id) : null, + label: b.label, + page_index: b.page_index, + y: b.y, + bounds: b.bounds, + source: b.source, + confirmed: b.confirmed, + }; +} + +async function replaceTemplate( + templateId: string, + detail: ExamTemplateDetail, + meta?: UpdateTemplateMetaPayload, + duplicateIds = false, +): Promise { + const headers = await authHeaders(); + const idMap = new Map(); + if (duplicateIds) { + detail.questions.forEach((q) => idMap.set(q.id, newUuid())); + } + const res = await axios.put( + `${EXAM_BASE}/templates/${templateId}`, + { + meta, + questions: detail.questions.map((q) => questionPayload(q, idMap)), + response_areas: detail.response_areas.map((r) => responseAreaPayload(r, idMap, duplicateIds)), + boundaries: detail.boundaries.map((b) => boundaryPayload(b, idMap, duplicateIds)), + }, + { headers }, + ); + return res.data; +} + export const examRepository = { async listTemplates(includeArchived = false): Promise { const headers = await authHeaders(); @@ -47,6 +132,38 @@ export const examRepository = { return res.data; }, + async updateTemplateMeta(templateId: string, meta: UpdateTemplateMetaPayload): Promise { + const headers = await authHeaders(); + const res = await axios.patch(`${EXAM_BASE}/templates/${templateId}`, meta, { headers }); + return res.data; + }, + + async duplicateTemplate(templateId: string, title: string): Promise { + const detail = await this.getTemplate(templateId); + const created = await this.createTemplate({ + title, + subject: detail.subject ?? undefined, + exam_id: detail.exam_id ?? undefined, + exam_code: detail.exam_code ?? undefined, + source_file_id: detail.source_file_id ?? undefined, + page_count: detail.page_count ?? undefined, + institute_id: detail.institute_id, + }); + try { + return await replaceTemplate(created.id, { ...detail, id: created.id }, { title, status: 'draft' }, true); + } catch (error) { + try { + await this.archiveTemplate(created.id); + } catch (archiveError) { + logger.error('cc-exam-marker', 'Failed to archive incomplete duplicate template', { + templateId: created.id, + message: archiveError instanceof Error ? archiveError.message : String(archiveError), + }); + } + throw error; + } + }, + async archiveTemplate(templateId: string): Promise { const headers = await authHeaders(); await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers }); diff --git a/src/types/exam.types.ts b/src/types/exam.types.ts index 017e0d1..48312d4 100644 --- a/src/types/exam.types.ts +++ b/src/types/exam.types.ts @@ -31,6 +31,13 @@ export interface CreateTemplatePayload { institute_id?: string; } +export interface UpdateTemplateMetaPayload { + title?: string; + subject?: string | null; + page_count?: number | null; + status?: ExamTemplateStatus; +} + /** Canvas children (used from S4-9 onward; defined here so the seam is complete). */ export interface ExamQuestion { id: string; @@ -44,16 +51,27 @@ export interface ExamQuestion { mark_scheme: Record; is_container: boolean; spec_ref: string | null; + bounds?: Record | null; + page?: number | null; } +export type ExamResponseAreaKind = + | 'response' + | 'context' + | 'question_number' + | 'mark_area' + | 'reference' + | 'furniture'; + export interface ExamResponseArea { id: string; question_id: string; template_id: string; page: number; bounds: Record; - kind: 'response' | 'context'; + kind: ExamResponseAreaKind; response_form: string | null; + context_type?: string | null; source: 'manual' | 'ai'; confirmed: boolean; confidence: number | null; From 1ab8aab43ce779c6f6f30950fb75b7f1047b8efc Mon Sep 17 00:00:00 2001 From: kcar Date: Sun, 7 Jun 2026 00:34:56 +0100 Subject: [PATCH 02/14] feat(exam): add mark scheme editor --- src/AppRoutes.admin.test.tsx | 24 +- src/AppRoutes.tsx | 3 +- src/pages/exam/ExamDashboardPage.tsx | 11 + src/pages/exam/MarkSchemePage.tsx | 501 +++++++++++++++++++++++++++ src/pages/exam/index.ts | 1 + src/services/exam/examRepository.ts | 25 ++ src/types/exam.types.ts | 68 +++- 7 files changed, 629 insertions(+), 4 deletions(-) create mode 100644 src/pages/exam/MarkSchemePage.tsx diff --git a/src/AppRoutes.admin.test.tsx b/src/AppRoutes.admin.test.tsx index a4346e5..501060e 100644 --- a/src/AppRoutes.admin.test.tsx +++ b/src/AppRoutes.admin.test.tsx @@ -31,7 +31,10 @@ vi.mock('./pages/NotFoundPublic', () => ({ default: () =>
Public Not Found< vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () =>
Public Home
})); vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () =>
Single Player
})); vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () =>
Multiplayer
})); -vi.mock('./pages/exam', () => ({ ExamDashboardPage: () =>
Exam Marker
})); +vi.mock('./pages/exam', () => ({ + ExamDashboardPage: () =>
Exam Marker
, + MarkSchemePage: () =>
Mark Scheme editor
, +})); vi.mock('./pages/user/calendarPage', () => ({ default: () =>
Calendar
})); vi.mock('./pages/user/settingsPage', () => ({ default: () =>
Settings
})); vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () =>
TLDraw Dev
})); @@ -122,3 +125,22 @@ describe('/admin route authorization', () => { expect(screen.getByText('Platform Admin Page')).toBeInTheDocument(); }); }); + + +describe('exam-marker routes', () => { + beforeEach(() => { + mockUseAuth.mockReset(); + }); + + it('renders the mark scheme editor route for authenticated users', () => { + mockUseAuth.mockReturnValue(authState({ + user: { id: 'teacher-1', email: 'teacher@example.com', user_type: 'email_teacher' }, + user_role: 'email_teacher', + accessToken: 'token', + })); + + renderAt('/exam-marker/template-123/marks'); + + expect(screen.getByText('Mark Scheme editor')).toBeInTheDocument(); + }); +}); diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index f091bfb..4c3330c 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -7,7 +7,7 @@ import LoginPage from './pages/auth/loginPage'; import SignupPage from './pages/auth/signupPage'; import SinglePlayerPage from './pages/tldraw/singlePlayerPage'; import MultiplayerUser from './pages/tldraw/multiplayerUser'; -import { ExamDashboardPage } from './pages/exam'; +import { ExamDashboardPage, MarkSchemePage } from './pages/exam'; import { ErrorBoundary } from './components/ErrorBoundary'; import CalendarPage from './pages/user/calendarPage'; import SettingsPage from './pages/user/settingsPage'; @@ -183,6 +183,7 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/pages/exam/ExamDashboardPage.tsx b/src/pages/exam/ExamDashboardPage.tsx index 500c45f..5080792 100644 --- a/src/pages/exam/ExamDashboardPage.tsx +++ b/src/pages/exam/ExamDashboardPage.tsx @@ -22,6 +22,7 @@ import { import AddIcon from '@mui/icons-material/Add'; import ArchiveIcon from '@mui/icons-material/Archive'; import AssignmentIcon from '@mui/icons-material/Assignment'; +import GradingIcon from '@mui/icons-material/Grading'; import { useAuth } from '../../contexts/AuthContext'; import { examRepository } from '../../services/exam/examRepository'; @@ -168,6 +169,16 @@ const ExamDashboardPage: React.FC = () => { {new Date(t.updated_at).toLocaleDateString()} + + + ))} diff --git a/src/pages/exam/MarkSchemePage.tsx b/src/pages/exam/MarkSchemePage.tsx new file mode 100644 index 0000000..39cd206 --- /dev/null +++ b/src/pages/exam/MarkSchemePage.tsx @@ -0,0 +1,501 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { + Alert, + Autocomplete, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Container, + Divider, + FormControl, + Grid, + InputLabel, + MenuItem, + Paper, + Select, + Stack, + Tab, + Tabs, + TextField, + Typography, +} from '@mui/material'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import SaveIcon from '@mui/icons-material/Save'; +import SyncIcon from '@mui/icons-material/Sync'; + +import { logger } from '../../debugConfig'; +import { examRepository } from '../../services/exam/examRepository'; +import type { + ExamQuestion, + ExamTemplateDetail, + MarkScheme, + MarkSchemeType, + SpecPoint, +} from '../../types/exam.types'; + +type Drafts = Record; + +interface PartDraft { + max_marks: string; + answer_type: 'written' | 'mcq' | 'short' | 'diagram'; + spec_ref: string; + schemeType: MarkSchemeType; + body: string; + notes: string; +} + +const ANSWER_TYPES: Array = ['written', 'short', 'mcq', 'diagram']; +const SCHEME_TYPES: Array<{ value: MarkSchemeType; label: string; helper: string }> = [ + { value: 'points', label: 'Points', helper: 'One mark point per line, e.g. "1 | Mentions resultant force".' }, + { value: 'levels', label: 'Levels', helper: 'One level per line, e.g. "L2 | 3-4 | Clear linked explanation".' }, + { value: 'parts', label: 'Parts', helper: 'One sub-part per line, e.g. "a | 2 | Correct substitution".' }, + { value: 'checklist', label: 'Checklist', helper: 'One checklist item per line, e.g. "1 | Correct unit".' }, + { value: 'free', label: 'Free text', helper: 'Paste or type the mark scheme exactly as it should be stored.' }, +]; + +function deriveSpecCode(template: ExamTemplateDetail | null): string { + const examCode = template?.exam_code ?? ''; + const match = examCode.match(/^([A-Z]+-[A-Z]+-\d+)/i); + return match?.[1] ?? ''; +} + +function schemeToDraft(scheme: MarkScheme | null | undefined): Pick { + const type = (scheme?.type as MarkSchemeType | undefined) ?? 'points'; + const notes = typeof scheme?.notes === 'string' ? scheme.notes : ''; + + if (type === 'levels' && Array.isArray(scheme?.levels)) { + return { + schemeType: 'levels', + body: scheme.levels.map((l) => `${l.level ?? ''} | ${l.min ?? 0}-${l.max ?? 0} | ${l.descriptor ?? ''}`).join('\n'), + notes, + }; + } + if (type === 'parts' && Array.isArray(scheme?.parts)) { + return { + schemeType: 'parts', + body: scheme.parts.map((p) => `${p.label ?? ''} | ${p.marks ?? 0} | ${p.guidance ?? ''}`).join('\n'), + notes, + }; + } + if (type === 'checklist' && Array.isArray(scheme?.checklist)) { + return { + schemeType: 'checklist', + body: scheme.checklist.map((item) => `${item.marks ?? 1} | ${item.text ?? ''}`).join('\n'), + notes, + }; + } + if (type === 'free') { + return { schemeType: 'free', body: typeof scheme?.text === 'string' ? scheme.text : '', notes }; + } + if (Array.isArray(scheme?.points)) { + return { + schemeType: 'points', + body: scheme.points.map((p) => `${p.mark ?? 1} | ${p.text ?? ''}`).join('\n'), + notes, + }; + } + return { schemeType: type, body: '', notes }; +} + +function parseNumber(value: string, fallback = 0): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function splitLine(line: string): string[] { + return line.split('|').map((part) => part.trim()); +} + +function draftToScheme(draft: PartDraft): MarkScheme { + const lines = draft.body.split('\n').map((line) => line.trim()).filter(Boolean); + if (draft.schemeType === 'free') return { type: 'free', text: draft.body, notes: draft.notes || undefined }; + if (draft.schemeType === 'levels') { + return { + type: 'levels', + levels: lines.map((line, idx) => { + const [level, range, descriptor] = splitLine(line); + const [minRaw, maxRaw] = (range || '').split('-').map((part) => part.trim()); + return { + level: level || `L${idx + 1}`, + min: parseNumber(minRaw, 0), + max: parseNumber(maxRaw, parseNumber(minRaw, 0)), + descriptor: descriptor || line, + }; + }), + notes: draft.notes || undefined, + }; + } + if (draft.schemeType === 'parts') { + return { + type: 'parts', + parts: lines.map((line, idx) => { + const [label, marks, guidance] = splitLine(line); + return { label: label || String(idx + 1), marks: parseNumber(marks, 1), guidance: guidance || line }; + }), + notes: draft.notes || undefined, + }; + } + if (draft.schemeType === 'checklist') { + return { + type: 'checklist', + checklist: lines.map((line) => { + const [marks, text] = splitLine(line); + return { marks: parseNumber(marks, 1), text: text || line }; + }), + notes: draft.notes || undefined, + }; + } + return { + type: 'points', + points: lines.map((line) => { + const [mark, text] = splitLine(line); + return { mark: parseNumber(mark, 1), text: text || line }; + }), + notes: draft.notes || undefined, + }; +} + +function makeDraft(question: ExamQuestion): PartDraft { + const scheme = schemeToDraft(question.mark_scheme); + return { + max_marks: String(question.max_marks ?? 0), + answer_type: (question.answer_type as PartDraft['answer_type'] | null) ?? 'written', + spec_ref: question.spec_ref ?? '', + schemeType: scheme.schemeType, + body: scheme.body, + notes: scheme.notes, + }; +} + +const MarkSchemePage: React.FC = () => { + const navigate = useNavigate(); + const { templateId } = useParams<{ templateId: string }>(); + + const [template, setTemplate] = useState(null); + const [selectedId, setSelectedId] = useState(null); + const [drafts, setDrafts] = useState({}); + const [specCode, setSpecCode] = useState(''); + const [specPoints, setSpecPoints] = useState([]); + const [loading, setLoading] = useState(true); + const [savingId, setSavingId] = useState(null); + const [syncing, setSyncing] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [specEndpointMissing, setSpecEndpointMissing] = useState(false); + + const load = useCallback(async () => { + if (!templateId) return; + setLoading(true); + setError(null); + try { + const detail = await examRepository.getTemplate(templateId); + const leafParts = detail.questions.filter((q) => !q.is_container).sort((a, b) => a.order - b.order || a.label.localeCompare(b.label)); + setTemplate(detail); + setSelectedId((prev) => prev ?? leafParts[0]?.id ?? null); + setDrafts(Object.fromEntries(leafParts.map((q) => [q.id, makeDraft(q)]))); + setSpecCode((prev) => prev || deriveSpecCode(detail)); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + logger.error('cc-exam-marker', 'Failed to load mark scheme template', { message: msg, templateId }); + setError(msg); + } finally { + setLoading(false); + } + }, [templateId]); + + useEffect(() => { + void load(); + }, [load]); + + const loadSpecPoints = useCallback(async () => { + if (!specCode.trim()) return; + setSpecEndpointMissing(false); + try { + setSpecPoints(await examRepository.listSpecPoints(specCode.trim())); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + logger.warn('cc-exam-marker', 'SpecPoint endpoint unavailable; falling back to manual spec_ref entry', { message: msg, specCode }); + setSpecEndpointMissing(true); + setSpecPoints([]); + } + }, [specCode]); + + useEffect(() => { + void loadSpecPoints(); + }, [loadSpecPoints]); + + const parts = useMemo( + () => (template?.questions ?? []).filter((q) => !q.is_container).sort((a, b) => a.order - b.order || a.label.localeCompare(b.label)), + [template], + ); + const selected = parts.find((p) => p.id === selectedId) ?? null; + const draft = selected ? drafts[selected.id] : null; + const selectedSpecPoint = draft?.spec_ref ? specPoints.find((p) => p.ref === draft.spec_ref) ?? null : null; + + const updateDraft = (questionId: string, patch: Partial) => { + setDrafts((prev) => ({ ...prev, [questionId]: { ...prev[questionId], ...patch } })); + }; + + const savePart = async (question: ExamQuestion) => { + const partDraft = drafts[question.id]; + if (!partDraft) return; + setSavingId(question.id); + setError(null); + setNotice(null); + try { + await examRepository.patchQuestion(question.id, { + max_marks: parseNumber(partDraft.max_marks, question.max_marks), + answer_type: partDraft.answer_type, + mark_scheme: draftToScheme(partDraft), + spec_ref: partDraft.spec_ref.trim() || null, + }); + setTemplate((prev) => { + if (!prev) return prev; + return { + ...prev, + questions: prev.questions.map((q) => q.id === question.id ? { + ...q, + max_marks: parseNumber(partDraft.max_marks, question.max_marks), + answer_type: partDraft.answer_type, + mark_scheme: draftToScheme(partDraft), + spec_ref: partDraft.spec_ref.trim() || null, + } : q), + }; + }); + setNotice(`Saved mark scheme for Part ${question.label}.`); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + logger.error('cc-exam-marker', 'Save mark scheme failed', { message: msg, questionId: question.id }); + setError(msg); + } finally { + setSavingId(null); + } + }; + + const syncGraph = async () => { + if (!templateId) return; + setSyncing(true); + setError(null); + setNotice(null); + try { + const res = await examRepository.syncTemplateToGraph(templateId); + setNotice(`Neo4j projection ${res.status}; ${JSON.stringify(res.projection ?? {})}`); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + logger.error('cc-exam-marker', 'Neo4j sync failed', { message: msg, templateId }); + setError(msg); + } finally { + setSyncing(false); + } + }; + + if (loading) { + return ; + } + + return ( + + + + + + Mark Scheme editor + + {template?.title ?? 'Template'} · Edit per-Part mark schemes and link assessed specification points. + + + + {templateId && } + + + + + {error && setError(null)}>{error}} + {notice && setNotice(null)}>{notice}} + {specEndpointMissing && ( + + SpecPoint picker endpoint was not available from this API build. You can still type a spec_ref manually; the backend projection resolves it during neo4j-sync. + + )} + + + + + setSpecCode(e.target.value)} + fullWidth + helperText="Example: AQA-PHYS-8463. Used by the SpecPoint picker." + /> + + + + Save writes PATCH /api/exam/questions/:id. Spec refs are persisted on the Part and projected as (:Part)-[:ASSESSES]->(:SpecPoint). + + + + + + {parts.length === 0 ? ( + + This template has no Parts yet. Draw Part boxes on the setup canvas first, then return here to enter mark schemes. + + ) : ( + + + + + Parts + setSelectedId(value)} + variant="scrollable" + sx={{ borderRight: 1, borderColor: 'divider', maxHeight: '65vh' }} + > + {parts.map((part) => ( + + Part {part.label} + + + {(drafts[part.id]?.spec_ref || part.spec_ref) && } + + + } + /> + ))} + + + + + + + {selected && draft && ( + + + + + + Part {selected.label} + + {selected.parent_id ? `Parent question ${selected.parent_id}` : 'No parent question'} + + + + + + + + updateDraft(selected.id, { max_marks: e.target.value })} + type="number" + inputProps={{ min: 0, step: 0.5 }} + fullWidth + /> + + + + Answer type + + + + + + Scheme form + + + + + + updateDraft(selected.id, { spec_ref: value?.ref ?? '' })} + getOptionLabel={(option) => `${option.ref} — ${option.description}`} + isOptionEqualToValue={(option, value) => option.ref === value.ref} + renderInput={(params) => ( + + )} + /> + + updateDraft(selected.id, { spec_ref: e.target.value })} + helperText="Persisted to exam_questions.spec_ref; graph sync creates the ASSESSES edge when the ref matches a seeded SpecPoint." + fullWidth + /> + + + + type.value === draft.schemeType)?.label ?? 'Mark scheme'} body`} + value={draft.body} + onChange={(e) => updateDraft(selected.id, { body: e.target.value })} + helperText={SCHEME_TYPES.find((type) => type.value === draft.schemeType)?.helper} + multiline + minRows={10} + fullWidth + /> + + updateDraft(selected.id, { notes: e.target.value })} + multiline + minRows={3} + fullWidth + /> + + + + )} + + + )} + + + ); +}; + +export default MarkSchemePage; diff --git a/src/pages/exam/index.ts b/src/pages/exam/index.ts index d8b8c5c..7f5a856 100644 --- a/src/pages/exam/index.ts +++ b/src/pages/exam/index.ts @@ -1 +1,2 @@ export { default as ExamDashboardPage } from './ExamDashboardPage'; +export { default as MarkSchemePage } from './MarkSchemePage'; diff --git a/src/services/exam/examRepository.ts b/src/services/exam/examRepository.ts index 1b5160d..d9fd7e5 100644 --- a/src/services/exam/examRepository.ts +++ b/src/services/exam/examRepository.ts @@ -13,6 +13,9 @@ import type { CreateTemplatePayload, ExamTemplate, ExamTemplateDetail, + Neo4jSyncResult, + PatchQuestionPayload, + SpecPoint, } from '../../types/exam.types'; const EXAM_BASE = `${API_BASE}/api/exam`; @@ -51,6 +54,28 @@ export const examRepository = { const headers = await authHeaders(); await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers }); }, + + async patchQuestion(questionId: string, payload: PatchQuestionPayload) { + const headers = await authHeaders(); + const res = await axios.patch(`${EXAM_BASE}/questions/${questionId}`, payload, { headers }); + return res.data; + }, + + async listSpecPoints(specCode: string, search?: string): Promise { + const headers = await authHeaders(); + const res = await axios.get<{ points?: SpecPoint[] } | SpecPoint[]>( + `${EXAM_BASE}/specs/${encodeURIComponent(specCode)}/points`, + { headers, params: search ? { q: search } : undefined }, + ); + if (Array.isArray(res.data)) return res.data; + return res.data.points ?? []; + }, + + async syncTemplateToGraph(templateId: string): Promise { + const headers = await authHeaders(); + const res = await axios.post(`${EXAM_BASE}/templates/${templateId}/neo4j-sync`, {}, { headers }); + return res.data; + }, }; export default examRepository; diff --git a/src/types/exam.types.ts b/src/types/exam.types.ts index 017e0d1..6f12c2d 100644 --- a/src/types/exam.types.ts +++ b/src/types/exam.types.ts @@ -31,6 +31,42 @@ export interface CreateTemplatePayload { institute_id?: string; } +export type MarkSchemeType = 'points' | 'levels' | 'parts' | 'checklist' | 'free'; + +export interface MarkSchemePoint { + mark: number; + text: string; +} + +export interface MarkSchemeLevel { + level: string; + min: number; + max: number; + descriptor: string; +} + +export interface MarkSchemePart { + label: string; + marks: number; + guidance: string; +} + +export interface MarkSchemeChecklistItem { + text: string; + marks: number; +} + +export interface MarkScheme { + type?: MarkSchemeType; + points?: MarkSchemePoint[]; + levels?: MarkSchemeLevel[]; + parts?: MarkSchemePart[]; + checklist?: MarkSchemeChecklistItem[]; + text?: string; + notes?: string; + [key: string]: unknown; +} + /** Canvas children (used from S4-9 onward; defined here so the seam is complete). */ export interface ExamQuestion { id: string; @@ -41,9 +77,11 @@ export interface ExamQuestion { max_marks: number; answer_type: string | null; mcq_options: unknown | null; - mark_scheme: Record; + mark_scheme: MarkScheme; is_container: boolean; spec_ref: string | null; + bounds?: Record | null; + page?: number | null; } export interface ExamResponseArea { @@ -52,8 +90,9 @@ export interface ExamResponseArea { template_id: string; page: number; bounds: Record; - kind: 'response' | 'context'; + kind: 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture'; response_form: string | null; + context_type?: string | null; source: 'manual' | 'ai'; confirmed: boolean; confidence: number | null; @@ -76,3 +115,28 @@ export interface ExamTemplateDetail extends ExamTemplate { response_areas: ExamResponseArea[]; boundaries: ExamBoundary[]; } + +export interface PatchQuestionPayload { + label?: string; + order?: number; + max_marks?: number; + answer_type?: 'written' | 'mcq' | 'short' | 'diagram' | null; + mcq_options?: unknown; + mark_scheme?: MarkScheme; + is_container?: boolean; + spec_ref?: string | null; +} + +export interface SpecPoint { + uid?: string; + uuid_string?: string; + ref: string; + description: string; + spec_code: string; + exam_board_code?: string; +} + +export interface Neo4jSyncResult { + status: string; + projection?: Record; +} From dea3275f23d5f314eb0623dee4dd81be32f93d30 Mon Sep 17 00:00:00 2001 From: kcar Date: Sun, 7 Jun 2026 01:27:44 +0100 Subject: [PATCH 03/14] [verified] Add exam template setup canvas --- src/AppRoutes.tsx | 3 +- src/pages/exam/index.ts | 1 + .../exam/setup/ExamTemplateSetupPage.tsx | 230 ++++++++++++++++++ src/pages/exam/setup/examCanvasShapes.tsx | 89 +++++++ src/services/exam/examRepository.ts | 7 + src/types/exam.types.ts | 46 ++++ src/utils/exam-canvas/model.test.ts | 49 ++++ src/utils/exam-canvas/model.ts | 129 ++++++++++ 8 files changed, 553 insertions(+), 1 deletion(-) create mode 100644 src/pages/exam/setup/ExamTemplateSetupPage.tsx create mode 100644 src/pages/exam/setup/examCanvasShapes.tsx create mode 100644 src/utils/exam-canvas/model.test.ts create mode 100644 src/utils/exam-canvas/model.ts diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index f091bfb..3efa91d 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -7,7 +7,7 @@ import LoginPage from './pages/auth/loginPage'; import SignupPage from './pages/auth/signupPage'; import SinglePlayerPage from './pages/tldraw/singlePlayerPage'; import MultiplayerUser from './pages/tldraw/multiplayerUser'; -import { ExamDashboardPage } from './pages/exam'; +import { ExamDashboardPage, ExamTemplateSetupPage } from './pages/exam'; import { ErrorBoundary } from './components/ErrorBoundary'; import CalendarPage from './pages/user/calendarPage'; import SettingsPage from './pages/user/settingsPage'; @@ -183,6 +183,7 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/pages/exam/index.ts b/src/pages/exam/index.ts index d8b8c5c..3169971 100644 --- a/src/pages/exam/index.ts +++ b/src/pages/exam/index.ts @@ -1 +1,2 @@ export { default as ExamDashboardPage } from './ExamDashboardPage'; +export { default as ExamTemplateSetupPage } from './setup/ExamTemplateSetupPage'; diff --git a/src/pages/exam/setup/ExamTemplateSetupPage.tsx b/src/pages/exam/setup/ExamTemplateSetupPage.tsx new file mode 100644 index 0000000..21a11a6 --- /dev/null +++ b/src/pages/exam/setup/ExamTemplateSetupPage.tsx @@ -0,0 +1,230 @@ + +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { Alert, Box, Button, Chip, CircularProgress, Divider, Paper, Snackbar, Stack, Tooltip, Typography, useTheme } from '@mui/material' +import ArrowBackIcon from '@mui/icons-material/ArrowBack' +import SaveIcon from '@mui/icons-material/Save' +import MouseIcon from '@mui/icons-material/Mouse' +import '@tldraw/tldraw/tldraw.css' +import { Editor, Tldraw, createShapeId, defaultBindingUtils, TLShape } from '@tldraw/tldraw' +import axios from 'axios' + +import { ErrorBoundary } from '../../../components/ErrorBoundary' +import { logger } from '../../../debugConfig' +import { examRepository } from '../../../services/exam/examRepository' +import type { ExamTemplateDetail } from '../../../types/exam.types' +import { ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model' +import { examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, shapeTypeToKind } from './examCanvasShapes' + +const TOOLS = [ + { id: 'select', label: 'Select', tip: 'Move, resize, or delete shapes.', color: 'default' as const }, + { id: SHAPE_TYPES.boundary, label: 'Boundary', tip: 'Draw one horizontal line. A main question is saved from each top+bottom pair.', color: 'error' as const }, + { id: SHAPE_TYPES.part, label: 'Part', tip: 'Draw the markable sub-question box inside a boundary pair.', color: 'warning' as const }, + { id: SHAPE_TYPES.response, label: 'Response', tip: 'Draw around where the student writes; saved with response_form=lines.', color: 'primary' as const }, + { id: SHAPE_TYPES.context, label: 'Context', tip: 'Draw around stimulus/context material; saved with context_type=generic.', color: 'secondary' as const }, + { id: SHAPE_TYPES.question_number, label: 'Q Number', tip: 'Box the printed question number.', color: 'success' as const }, + { id: SHAPE_TYPES.mark_area, label: 'Mark Area', tip: 'Box printed marks such as [2].', color: 'success' as const }, + { id: SHAPE_TYPES.reference, label: 'Reference', tip: 'Box student resources/reference material.', color: 'info' as const }, + { id: SHAPE_TYPES.furniture, label: 'Furniture', tip: 'Mark margins/page numbers/ignored decoration.', color: 'default' as const }, +] + +function apiMessage(err: unknown): { message: string; conflict: boolean } { + if (axios.isAxiosError(err)) { + const detail = (err.response?.data as { detail?: string } | undefined)?.detail + if (err.response?.status === 409) return { conflict: true, message: detail ?? 'Template has recorded marks; structural full-replace is blocked.' } + return { conflict: false, message: detail ?? err.message } + } + return { conflict: false, message: err instanceof Error ? err.message : String(err) } +} + +function stripShapePrefix(id: string) { + return id.startsWith('shape:') ? id.slice('shape:'.length) : id +} + +function domainIdForShape(shape: ExamCanvasTLShape): string { + const fromProps = shape.props.domainId + if (isUuid(fromProps)) return fromProps + const fromShapeId = stripShapePrefix(shape.id) + return isUuid(fromShapeId) ? fromShapeId : newDomainId() +} + +function ensureDomainIds(editor: Editor) { + const updates = editor.getCurrentPageShapes() + .filter((shape): shape is ExamCanvasTLShape => !!shapeTypeToKind(shape.type)) + .filter((shape) => !isUuid(shape.props.domainId)) + .map((shape) => ({ id: shape.id, type: shape.type, props: { domainId: domainIdForShape(shape) } })) + if (updates.length) editor.updateShapes(updates) +} + +function modelFromTLShape(shape: TLShape): ExamCanvasShapeModel | null { + const kind = shapeTypeToKind(shape.type) + if (!kind) return null + const s = shape as ExamCanvasTLShape + return { + id: domainIdForShape(s), + kind, + x: Number(s.x ?? 0), + y: Number(s.y ?? 0), + w: Number(s.props.w ?? 1), + h: Number(s.props.h ?? 1), + label: s.props.label, + maxMarks: s.props.maxMarks, + responseForm: s.props.responseForm as ExamCanvasShapeModel['responseForm'], + contextType: s.props.contextType, + questionId: s.props.questionId ?? null, + } +} + +function loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) { + const existing = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)).map((s) => s.id) + if (existing.length) editor.deleteShapes(existing) + if (!models.length) return + editor.createShapes(models.map((m) => ({ + id: createShapeId(m.id), + type: SHAPE_TYPES[m.kind], + x: m.x, + y: m.y, + props: { w: m.w, h: m.h, label: m.label ?? m.kind, kind: m.kind, maxMarks: m.maxMarks, responseForm: m.responseForm, contextType: m.contextType, questionId: m.questionId, domainId: m.id }, + }))) +} + +function seedGuide(editor: Editor) { + const current = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)) + if (current.length) return + editor.createShapes([ + { id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: 120, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', label: 'Q1 start', domainId: newDomainId() } }, + { id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: 520, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', label: 'Q1 end', domainId: newDomainId() } }, + { id: createShapeId(newDomainId()), type: SHAPE_TYPES.part, x: 92, y: 180, props: { w: 520, h: 150, kind: 'part', label: 'Q1(a)', maxMarks: 3, domainId: newDomainId() } }, + { id: createShapeId(newDomainId()), type: SHAPE_TYPES.response, x: 116, y: 355, props: { w: 470, h: 120, kind: 'response', label: 'Response', responseForm: 'lines', domainId: newDomainId() } }, + ]) +} + +const ExamTemplateSetupInner: React.FC = () => { + const { templateId } = useParams<{ templateId: string }>() + const navigate = useNavigate() + const theme = useTheme() + const editorRef = useRef(null) + const [template, setTemplate] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [dirty, setDirty] = useState(false) + const [error, setError] = useState(null) + const [conflict, setConflict] = useState(null) + const [activeTool, setActiveTool] = useState('select') + + const load = useCallback(async () => { + if (!templateId) return + setLoading(true); setError(null); setConflict(null) + try { + const detail = await examRepository.getTemplate(templateId) + setTemplate(detail) + const editor = editorRef.current + if (editor) loadShapes(editor, shapesFromTemplate(detail)) + setDirty(false) + } catch (e) { + const msg = apiMessage(e).message + logger.warn('cc-exam-marker', 'Template setup load failed', { templateId, message: msg }) + setError(msg) + } finally { + setLoading(false) + } + }, [templateId]) + + useEffect(() => { void load() }, [load]) + + const save = useCallback(async () => { + const editor = editorRef.current + if (!editor || !templateId || !template) return + setSaving(true); setError(null); setConflict(null) + try { + ensureDomainIds(editor) + const shapes = editor.getCurrentPageShapes().map(modelFromTLShape).filter(Boolean) as ExamCanvasShapeModel[] + const payload = serializeCanvasShapes(template, shapes) + const saved = await examRepository.replaceTemplate(templateId, payload) + setTemplate(saved) + loadShapes(editor, shapesFromTemplate(saved)) + setDirty(false) + } catch (e) { + const msg = apiMessage(e) + if (msg.conflict) setConflict(msg.message); else setError(msg.message) + logger.warn('cc-exam-marker', 'Template setup save failed', { templateId, message: msg.message }) + } finally { + setSaving(false) + } + }, [template, templateId]) + + const toolButtons = useMemo(() => TOOLS.map((tool) => ( + + + + )), [activeTool]) + + return ( + t.zIndex.drawer + 20, bgcolor: 'background.default' }}> + + { + editorRef.current = editor + editor.user.updateUserPreferences({ isDarkMode: theme.palette.mode === 'dark' }) + editor.store.listen(() => setDirty(true), { scope: 'document' }) + if (template) loadShapes(editor, shapesFromTemplate(template)); else seedGuide(editor) + }} + /> + + + + + + + {template?.title ?? 'Template setup'} + Exam Marker › Setup · draw boundaries, part boxes, and regions; Save persists a full replace. + + + + + + + {toolButtons} + + + + Setup guide + + 1) Draw top and bottom Boundary lines for each main question. 2) Draw Part boxes inside the pair. 3) Draw Response/Context/metadata regions inside a Part. Save derives parent links by spatial containment and reloads from the API. + + + + {loading && } + {conflict && setConflict(null)}>{conflict}} + setError(null)}> setError(null)}>{error} + + ) +} + +const ExamTemplateSetupPage: React.FC = () => ( + Template setup canvas crashed. Reload the page and try again.}> + + +) + +export default ExamTemplateSetupPage diff --git a/src/pages/exam/setup/examCanvasShapes.tsx b/src/pages/exam/setup/examCanvasShapes.tsx new file mode 100644 index 0000000..d066514 --- /dev/null +++ b/src/pages/exam/setup/examCanvasShapes.tsx @@ -0,0 +1,89 @@ + +import React from 'react' +import { BaseBoxShapeTool, BaseBoxShapeUtil, HTMLContainer, T, TLBaseBoxShape, toDomPrecision } from '@tldraw/tldraw' +import type { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model' + +export const SHAPE_TYPES = { + boundary: 'exam-boundary', + part: 'exam-part', + response: 'exam-region-response', + context: 'exam-region-context', + question_number: 'exam-region-question-number', + mark_area: 'exam-region-mark-area', + reference: 'exam-region-reference', + furniture: 'exam-region-furniture', +} as const + +export type ExamCanvasTLShape = TLBaseBoxShape & { + type: typeof SHAPE_TYPES[keyof typeof SHAPE_TYPES] + props: { + w: number + h: number + label: string + kind: ExamCanvasShapeKind + maxMarks?: number + responseForm?: string + contextType?: string + questionId?: string | null + domainId?: string + } +} + +const palette: Record = { + boundary: { stroke: '#ef4444', fill: 'rgba(239,68,68,0.06)', dash: '8 6', label: 'Boundary' }, + part: { stroke: '#f59e0b', fill: 'rgba(245,158,11,0.16)', label: 'Part' }, + response: { stroke: '#2563eb', fill: 'rgba(37,99,235,0.16)', label: 'Response' }, + context: { stroke: '#7c3aed', fill: 'rgba(124,58,237,0.14)', dash: '6 5', label: 'Context' }, + question_number: { stroke: '#0f766e', fill: 'rgba(15,118,110,0.14)', label: 'Question #' }, + mark_area: { stroke: '#16a34a', fill: 'rgba(22,163,74,0.14)', label: 'Marks' }, + reference: { stroke: '#0891b2', fill: 'rgba(8,145,178,0.14)', label: 'Reference' }, + furniture: { stroke: '#64748b', fill: 'rgba(100,116,139,0.12)', dash: '3 5', label: 'Furniture' }, +} + +function renderShape(shape: ExamCanvasTLShape) { + const kind = shape.props.kind + const p = palette[kind] ?? palette.response + const isBoundary = kind === 'boundary' + return ( + +
+ + {shape.props.label || p.label} + + {!isBoundary && shape.props.questionId && Attached} +
+
+ ) +} + +function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) { + const p = palette[kind] + return { w, h, label: p.label, kind, responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined } +} + +class BoundaryUtil extends BaseBoxShapeUtil { static override type = SHAPE_TYPES.boundary; static override props = { w: T.number, h: T.number, label: T.string, kind: T.string, maxMarks: T.optional(T.number), responseForm: T.optional(T.string), contextType: T.optional(T.string), questionId: T.optional(T.string), domainId: T.optional(T.string) }; override getDefaultProps(){ return defaultProps('boundary', 680, 8) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) } } +class PartUtil extends BaseBoxShapeUtil { static override type = SHAPE_TYPES.part; static override props = BoundaryUtil.props; override getDefaultProps(){ return defaultProps('part', 420, 170) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) } } +function regionUtil(type: string, kind: ExamCanvasRegionKind, w = 360, h = 120) { return class extends BaseBoxShapeUtil { static override type = type; static override props = BoundaryUtil.props; override getDefaultProps(){ return defaultProps(kind, w, h) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) } } } + +class BoundaryTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.boundary; static override initial = 'pointing'; shapeType = SHAPE_TYPES.boundary } +class PartTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.part; static override initial = 'pointing'; shapeType = SHAPE_TYPES.part } +class ResponseTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.response; static override initial = 'pointing'; shapeType = SHAPE_TYPES.response } +class ContextTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.context; static override initial = 'pointing'; shapeType = SHAPE_TYPES.context } +class QuestionNumberTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.question_number; static override initial = 'pointing'; shapeType = SHAPE_TYPES.question_number } +class MarkAreaTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.mark_area; static override initial = 'pointing'; shapeType = SHAPE_TYPES.mark_area } +class ReferenceTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.reference; static override initial = 'pointing'; shapeType = SHAPE_TYPES.reference } +class FurnitureTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.furniture; static override initial = 'pointing'; shapeType = SHAPE_TYPES.furniture } + +export const examCanvasShapeUtils = [BoundaryUtil, PartUtil, regionUtil(SHAPE_TYPES.response, 'response'), regionUtil(SHAPE_TYPES.context, 'context'), regionUtil(SHAPE_TYPES.question_number, 'question_number', 170, 80), regionUtil(SHAPE_TYPES.mark_area, 'mark_area', 170, 80), regionUtil(SHAPE_TYPES.reference, 'reference'), regionUtil(SHAPE_TYPES.furniture, 'furniture')] as const +export const examCanvasTools = [BoundaryTool, PartTool, ResponseTool, ContextTool, QuestionNumberTool, MarkAreaTool, ReferenceTool, FurnitureTool] as const + +export function shapeTypeToKind(type: string): ExamCanvasShapeKind | null { + const entry = Object.entries(SHAPE_TYPES).find(([, v]) => v === type) + return (entry?.[0] as ExamCanvasShapeKind | undefined) ?? null +} diff --git a/src/services/exam/examRepository.ts b/src/services/exam/examRepository.ts index e7d8d99..e4c0913 100644 --- a/src/services/exam/examRepository.ts +++ b/src/services/exam/examRepository.ts @@ -17,6 +17,7 @@ import type { ExamResponseArea, ExamTemplate, ExamTemplateDetail, + TemplateReplacePayload, UpdateTemplateMetaPayload, } from '../../types/exam.types'; @@ -164,6 +165,12 @@ export const examRepository = { } }, + + async replaceTemplate(templateId: string, payload: TemplateReplacePayload): Promise { + const headers = await authHeaders(); + const res = await axios.put(`${EXAM_BASE}/templates/${templateId}`, payload, { headers }); + return res.data; + }, async archiveTemplate(templateId: string): Promise { const headers = await authHeaders(); await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers }); diff --git a/src/types/exam.types.ts b/src/types/exam.types.ts index 48312d4..2cc2bfc 100644 --- a/src/types/exam.types.ts +++ b/src/types/exam.types.ts @@ -94,3 +94,49 @@ export interface ExamTemplateDetail extends ExamTemplate { response_areas: ExamResponseArea[]; boundaries: ExamBoundary[]; } + + +export interface TemplateReplacePayload { + meta?: { + title?: string; + subject?: string; + page_count?: number; + status?: ExamTemplateStatus; + }; + questions: Array<{ + id?: string; + parent_id?: string | null; + label: string; + order?: number; + max_marks?: number; + answer_type?: 'written' | 'mcq' | 'short' | 'diagram' | null; + mcq_options?: unknown | null; + mark_scheme?: Record; + is_container?: boolean; + spec_ref?: string | null; + bounds?: Record | null; + page?: number | null; + }>; + response_areas: Array<{ + id?: string; + question_id: string; + page: number; + bounds: Record; + kind: ExamResponseArea['kind']; + response_form?: string | null; + context_type?: string | null; + source?: 'manual' | 'ai'; + confirmed?: boolean; + confidence?: number | null; + }>; + boundaries: Array<{ + id?: string; + question_id?: string | null; + label?: string | null; + page_index: number; + y: number; + bounds?: Record | null; + source?: 'manual' | 'ai'; + confirmed?: boolean; + }>; +} diff --git a/src/utils/exam-canvas/model.test.ts b/src/utils/exam-canvas/model.test.ts new file mode 100644 index 0000000..2142dc9 --- /dev/null +++ b/src/utils/exam-canvas/model.test.ts @@ -0,0 +1,49 @@ + +import { describe, expect, it } from 'vitest' +import type { ExamTemplateDetail } from '../../types/exam.types' +import { isUuid, serializeCanvasShapes, shapesFromTemplate } from './model' + +const template: ExamTemplateDetail = { + id: 'tpl-1', title: 'Physics', subject: 'Physics', exam_id: null, exam_code: null, source_file_id: null, page_count: 1, + institute_id: 'inst', teacher_id: 'teacher', status: 'draft', created_at: 'now', updated_at: 'now', questions: [], response_areas: [], boundaries: [], +} + +describe('exam setup canvas serialization', () => { + it('pairs boundaries into a main question, attaches a Part, and attaches a response by containment', () => { + const payload = serializeCanvasShapes(template, [ + { id: 'b-top', kind: 'boundary', x: 40, y: 100, w: 700, h: 8, label: 'Q1 start' }, + { id: 'b-bottom', kind: 'boundary', x: 40, y: 700, w: 700, h: 8, label: 'Q1 end' }, + { id: 'part-1', kind: 'part', x: 100, y: 180, w: 400, h: 220, label: 'Q1(a)', maxMarks: 3 }, + { id: 'resp-1', kind: 'response', x: 130, y: 250, w: 300, h: 90, responseForm: 'lines' }, + ]) + + const main = payload.questions.find((q) => q.is_container) + const part = payload.questions.find((q) => !q.is_container) + expect(main?.label).toBe('Q1') + expect(part?.parent_id).toBe(main?.id) + expect(part?.bounds).toEqual({ x: 100, y: 180, w: 400, h: 220 }) + expect(payload.response_areas[0]).toMatchObject({ question_id: part?.id, kind: 'response', response_form: 'lines' }) + expect(payload.boundaries).toHaveLength(2) + expect(payload.boundaries.every((b) => b.question_id === main?.id)).toBe(true) + expect(payload.questions.every((q) => isUuid(q.id))).toBe(true) + expect(payload.response_areas.every((r) => isUuid(r.id))).toBe(true) + expect(payload.boundaries.every((b) => isUuid(b.id))).toBe(true) + }) + + it('round-trips saved Part geometry and all S4-9 region kinds from API detail', () => { + const shapes = shapesFromTemplate({ + ...template, + questions: [ + { id: 'q1', template_id: 'tpl-1', parent_id: null, label: 'Q1', order: 0, max_marks: 0, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null }, + { id: 'p1', template_id: 'tpl-1', parent_id: 'q1', label: 'Q1(a)', order: 0, max_marks: 2, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 1, y: 2, w: 3, h: 4 }, page: 1 }, + ], + response_areas: [ + { id: 'r1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 10, y: 20, w: 30, h: 40 }, kind: 'response', response_form: 'lines', source: 'manual', confirmed: true, confidence: null }, + { id: 'f1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 11, y: 21, w: 31, h: 41 }, kind: 'furniture', response_form: null, source: 'manual', confirmed: true, confidence: null }, + ], + boundaries: [{ id: 'b1', template_id: 'tpl-1', question_id: 'q1', label: 'Q1 start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'manual', confirmed: true }], + }) + expect(shapes.map((s) => s.kind).sort()).toEqual(['boundary', 'furniture', 'part', 'response']) + expect(shapes.find((s) => s.kind === 'part')).toMatchObject({ id: 'p1', x: 1, y: 2, w: 3, h: 4 }) + }) +}) diff --git a/src/utils/exam-canvas/model.ts b/src/utils/exam-canvas/model.ts new file mode 100644 index 0000000..00ea3ae --- /dev/null +++ b/src/utils/exam-canvas/model.ts @@ -0,0 +1,129 @@ + +import type { ExamTemplateDetail, TemplateReplacePayload } from '../../types/exam.types' + +export const PAGE_HEIGHT = 1100 +export const PAGE_WIDTH = 780 + +export type ExamCanvasRegionKind = 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture' +export type ExamCanvasShapeKind = 'boundary' | 'part' | ExamCanvasRegionKind + +export interface CanvasBounds { x: number; y: number; w: number; h: number } + +export interface ExamCanvasShapeModel { + /** Stable domain UUID persisted to Supabase. Do not reuse tldraw shape ids for new shapes. */ + id: string + kind: ExamCanvasShapeKind + x: number + y: number + w: number + h: number + label?: string + maxMarks?: number + answerType?: 'written' | 'mcq' | 'short' | 'diagram' + responseForm?: 'lines' | 'answer-box' | 'working' | 'diagram' | 'tick-boxes' | 'table' | 'blanks' + contextType?: string + questionId?: string | null +} + +export function pageForY(y: number): number { + return Math.max(1, Math.floor(y / PAGE_HEIGHT) + 1) +} + +export function isUuid(value: string | null | undefined): value is string { + return typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value) +} + +export function newDomainId(): string { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID() + } + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = Math.floor(Math.random() * 16) + const v = c === 'x' ? r : (r % 4) + 8 + return v.toString(16) + }) +} + +function bounds(shape: Pick): CanvasBounds { + return { x: shape.x, y: shape.y, w: shape.w, h: shape.h } +} + +function contains(outer: CanvasBounds, inner: CanvasBounds): boolean { + const ox2 = outer.x + outer.w + const oy2 = outer.y + outer.h + const ix2 = inner.x + inner.w + const iy2 = inner.y + inner.h + return inner.x >= outer.x && inner.y >= outer.y && ix2 <= ox2 && iy2 <= oy2 +} + +function bandContains(top: ExamCanvasShapeModel, bottom: ExamCanvasShapeModel, shape: ExamCanvasShapeModel): boolean { + const minY = Math.min(top.y, bottom.y) + const maxY = Math.max(top.y, bottom.y) + const cy = shape.y + shape.h / 2 + return cy >= minY && cy <= maxY +} + +export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: ExamCanvasShapeModel[]): TemplateReplacePayload { + const orderedBoundaries = shapes + .filter((s) => s.kind === 'boundary') + .sort((a, b) => (pageForY(a.y) - pageForY(b.y)) || (a.y - b.y)) + const parts = shapes.filter((s) => s.kind === 'part') + const regions = shapes.filter((s) => s.kind !== 'boundary' && s.kind !== 'part') + + const questions: TemplateReplacePayload['questions'] = [] + const boundaries: TemplateReplacePayload['boundaries'] = [] + const bands: Array<{ questionId: string; top: ExamCanvasShapeModel; bottom: ExamCanvasShapeModel }> = [] + + for (let i = 0; i < orderedBoundaries.length; i += 2) { + const top = orderedBoundaries[i] + const bottom = orderedBoundaries[i + 1] + if (!top || !bottom) break + const qNum = bands.length + 1 + const questionId = isUuid(top.questionId) ? top.questionId : isUuid(bottom.questionId) ? bottom.questionId : newDomainId() + const label = top.label?.replace(/\s+(start|end)$/i, '') || bottom.label?.replace(/\s+(start|end)$/i, '') || `Q${qNum}` + questions.push({ id: questionId, label, order: qNum - 1, max_marks: 0, is_container: true, mark_scheme: {} }) + bands.push({ questionId, top, bottom }) + for (const b of [top, bottom]) { + boundaries.push({ id: isUuid(b.id) ? b.id : newDomainId(), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForY(b.y) - 1, y: b.y, bounds: bounds(b), source: 'manual', confirmed: true }) + } + } + + const partQuestionIds = new Map() + parts.sort((a, b) => (a.y - b.y) || (a.x - b.x)).forEach((part, index) => { + const parentBand = bands.find((band) => bandContains(band.top, band.bottom, part)) + const qid = isUuid(part.questionId) ? part.questionId : isUuid(part.id) ? part.id : newDomainId() + partQuestionIds.set(part.id, qid) + questions.push({ id: qid, parent_id: parentBand?.questionId ?? null, label: part.label || `Part ${index + 1}`, order: index, max_marks: Number(part.maxMarks ?? 0), answer_type: part.answerType ?? 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: bounds(part), page: pageForY(part.y) }) + }) + + const response_areas: TemplateReplacePayload['response_areas'] = [] + for (const region of regions) { + const containingPart = parts.find((part) => contains(bounds(part), bounds(region))) + const fallbackPart = parts.find((part) => pageForY(part.y) === pageForY(region.y)) ?? parts[0] + const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined + if (!questionId) continue + const kind = region.kind as ExamCanvasRegionKind + response_areas.push({ id: isUuid(region.id) ? region.id : newDomainId(), question_id: questionId, page: pageForY(region.y), bounds: bounds(region), kind, response_form: kind === 'response' ? (region.responseForm ?? 'lines') : null, context_type: kind === 'context' ? (region.contextType ?? 'generic') : null, source: 'manual', confirmed: true, confidence: null }) + } + + return { meta: { title: template.title, subject: template.subject ?? undefined, page_count: template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries } +} + +export function shapesFromTemplate(detail: ExamTemplateDetail): ExamCanvasShapeModel[] { + const shapes: ExamCanvasShapeModel[] = [] + const questions = new Map(detail.questions.map((q) => [q.id, q])) + for (const b of detail.boundaries ?? []) { + const bb = b.bounds ?? { x: 48, y: b.y, w: PAGE_WIDTH - 96, h: 8 } + shapes.push({ id: b.id, kind: 'boundary', x: Number(bb.x ?? 48), y: Number(bb.y ?? b.y), w: Number(bb.w ?? PAGE_WIDTH - 96), h: Number(bb.h ?? 8), label: b.label ?? undefined, questionId: b.question_id }) + } + for (const q of detail.questions ?? []) { + if (q.is_container || !q.bounds) continue + shapes.push({ id: q.id, kind: 'part', x: Number(q.bounds.x ?? 80), y: Number(q.bounds.y ?? 120), w: Number(q.bounds.w ?? 420), h: Number(q.bounds.h ?? 180), label: q.label, maxMarks: q.max_marks, answerType: q.answer_type ?? 'written', questionId: q.id }) + } + for (const r of detail.response_areas ?? []) { + const bb = r.bounds ?? { x: 100, y: (r.page - 1) * PAGE_HEIGHT + 360, w: 360, h: 120 } + const q = questions.get(r.question_id) + shapes.push({ id: r.id, kind: r.kind, x: Number(bb.x ?? 100), y: Number(bb.y ?? (r.page - 1) * PAGE_HEIGHT + 360), w: Number(bb.w ?? 360), h: Number(bb.h ?? 120), label: q ? `→ ${q.label}` : r.kind, responseForm: r.response_form ?? undefined, contextType: r.context_type ?? undefined, questionId: r.question_id }) + } + return shapes +} From 16ae3aa089fe7cd2ae0688b099105048a66bfed5 Mon Sep 17 00:00:00 2001 From: CC Worker Date: Sun, 7 Jun 2026 01:03:08 +0000 Subject: [PATCH 04/14] =?UTF-8?q?fix(exam):=20setup=20toolbar=20crash=20?= =?UTF-8?q?=E2=80=94=20MUI=20Button=20color=20'default'=20is=20invalid=20i?= =?UTF-8?q?n=20v5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Select + Furniture tool buttons used color='default', which MUI v5 resolves to theme.palette.default (undefined) -> TypeError reading 'main'/'dark' at render, tripping the ErrorBoundary ('Template setup canvas crashed'). Use 'inherit'. Caught by live browser-verify t_11f5a049; model-level tests never rendered the toolbar. Co-Authored-By: Claude Opus 4.8 --- src/pages/exam/setup/ExamTemplateSetupPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/exam/setup/ExamTemplateSetupPage.tsx b/src/pages/exam/setup/ExamTemplateSetupPage.tsx index 21a11a6..07cc21c 100644 --- a/src/pages/exam/setup/ExamTemplateSetupPage.tsx +++ b/src/pages/exam/setup/ExamTemplateSetupPage.tsx @@ -17,7 +17,7 @@ import { ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, ser import { examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, shapeTypeToKind } from './examCanvasShapes' const TOOLS = [ - { id: 'select', label: 'Select', tip: 'Move, resize, or delete shapes.', color: 'default' as const }, + { id: 'select', label: 'Select', tip: 'Move, resize, or delete shapes.', color: 'inherit' as const }, { id: SHAPE_TYPES.boundary, label: 'Boundary', tip: 'Draw one horizontal line. A main question is saved from each top+bottom pair.', color: 'error' as const }, { id: SHAPE_TYPES.part, label: 'Part', tip: 'Draw the markable sub-question box inside a boundary pair.', color: 'warning' as const }, { id: SHAPE_TYPES.response, label: 'Response', tip: 'Draw around where the student writes; saved with response_form=lines.', color: 'primary' as const }, @@ -25,7 +25,7 @@ const TOOLS = [ { id: SHAPE_TYPES.question_number, label: 'Q Number', tip: 'Box the printed question number.', color: 'success' as const }, { id: SHAPE_TYPES.mark_area, label: 'Mark Area', tip: 'Box printed marks such as [2].', color: 'success' as const }, { id: SHAPE_TYPES.reference, label: 'Reference', tip: 'Box student resources/reference material.', color: 'info' as const }, - { id: SHAPE_TYPES.furniture, label: 'Furniture', tip: 'Mark margins/page numbers/ignored decoration.', color: 'default' as const }, + { id: SHAPE_TYPES.furniture, label: 'Furniture', tip: 'Mark margins/page numbers/ignored decoration.', color: 'inherit' as const }, ] function apiMessage(err: unknown): { message: string; conflict: boolean } { From ab6f0b09d730b9cf7da5c52965a89844b95ee732 Mon Sep 17 00:00:00 2001 From: CC Worker Date: Sun, 7 Jun 2026 02:21:32 +0000 Subject: [PATCH 05/14] fix: remove duplicate bindingUtils causing tldraw arrow double-registration Tldraw 3.6.1 Tldraw component already registers defaultBindingUtils internally; passing them explicitly via the bindingUtils prop caused "Binding type 'arrow' is defined more than once" runtime crash. Co-Authored-By: Claude Sonnet 4.6 --- src/pages/exam/setup/ExamTemplateSetupPage.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pages/exam/setup/ExamTemplateSetupPage.tsx b/src/pages/exam/setup/ExamTemplateSetupPage.tsx index 07cc21c..72127f8 100644 --- a/src/pages/exam/setup/ExamTemplateSetupPage.tsx +++ b/src/pages/exam/setup/ExamTemplateSetupPage.tsx @@ -6,7 +6,7 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack' import SaveIcon from '@mui/icons-material/Save' import MouseIcon from '@mui/icons-material/Mouse' import '@tldraw/tldraw/tldraw.css' -import { Editor, Tldraw, createShapeId, defaultBindingUtils, TLShape } from '@tldraw/tldraw' +import { Editor, Tldraw, createShapeId, TLShape } from '@tldraw/tldraw' import axios from 'axios' import { ErrorBoundary } from '../../../components/ErrorBoundary' @@ -178,7 +178,6 @@ const ExamTemplateSetupInner: React.FC = () => { Date: Sun, 7 Jun 2026 02:30:57 +0000 Subject: [PATCH 06/14] fix: tldraw user prefs colorScheme and indicator method for shape utils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - updateUserPreferences: isDarkMode → colorScheme ('dark'|'light') per tldraw 3.6.1 TLUserPreferences type (isDarkMode is read-only computed) - BaseBoxShapeUtil subclasses: add required indicator() method returning bounding rect; fixes non-abstract class missing abstract member error Co-Authored-By: Claude Sonnet 4.6 --- src/pages/exam/setup/ExamTemplateSetupPage.tsx | 2 +- src/pages/exam/setup/examCanvasShapes.tsx | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/pages/exam/setup/ExamTemplateSetupPage.tsx b/src/pages/exam/setup/ExamTemplateSetupPage.tsx index 72127f8..e9ad39b 100644 --- a/src/pages/exam/setup/ExamTemplateSetupPage.tsx +++ b/src/pages/exam/setup/ExamTemplateSetupPage.tsx @@ -184,7 +184,7 @@ const ExamTemplateSetupInner: React.FC = () => { autoFocus onMount={(editor) => { editorRef.current = editor - editor.user.updateUserPreferences({ isDarkMode: theme.palette.mode === 'dark' }) + editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' }) editor.store.listen(() => setDirty(true), { scope: 'document' }) if (template) loadShapes(editor, shapesFromTemplate(template)); else seedGuide(editor) }} diff --git a/src/pages/exam/setup/examCanvasShapes.tsx b/src/pages/exam/setup/examCanvasShapes.tsx index d066514..bdd6596 100644 --- a/src/pages/exam/setup/examCanvasShapes.tsx +++ b/src/pages/exam/setup/examCanvasShapes.tsx @@ -67,9 +67,11 @@ function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) { return { w, h, label: p.label, kind, responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined } } -class BoundaryUtil extends BaseBoxShapeUtil { static override type = SHAPE_TYPES.boundary; static override props = { w: T.number, h: T.number, label: T.string, kind: T.string, maxMarks: T.optional(T.number), responseForm: T.optional(T.string), contextType: T.optional(T.string), questionId: T.optional(T.string), domainId: T.optional(T.string) }; override getDefaultProps(){ return defaultProps('boundary', 680, 8) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) } } -class PartUtil extends BaseBoxShapeUtil { static override type = SHAPE_TYPES.part; static override props = BoundaryUtil.props; override getDefaultProps(){ return defaultProps('part', 420, 170) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) } } -function regionUtil(type: string, kind: ExamCanvasRegionKind, w = 360, h = 120) { return class extends BaseBoxShapeUtil { static override type = type; static override props = BoundaryUtil.props; override getDefaultProps(){ return defaultProps(kind, w, h) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) } } } +const sharedProps = { w: T.number, h: T.number, label: T.string, kind: T.string, maxMarks: T.optional(T.number), responseForm: T.optional(T.string), contextType: T.optional(T.string), questionId: T.optional(T.string), domainId: T.optional(T.string) } +const ind = (s: ExamCanvasTLShape) => +class BoundaryUtil extends BaseBoxShapeUtil { static override type = SHAPE_TYPES.boundary; static override props = sharedProps; override getDefaultProps(){ return defaultProps('boundary', 680, 8) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } } +class PartUtil extends BaseBoxShapeUtil { static override type = SHAPE_TYPES.part; static override props = sharedProps; override getDefaultProps(){ return defaultProps('part', 420, 170) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } } +function regionUtil(type: string, kind: ExamCanvasRegionKind, w = 360, h = 120) { return class extends BaseBoxShapeUtil { static override type = type; static override props = sharedProps; override getDefaultProps(){ return defaultProps(kind, w, h) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } } } class BoundaryTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.boundary; static override initial = 'pointing'; shapeType = SHAPE_TYPES.boundary } class PartTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.part; static override initial = 'pointing'; shapeType = SHAPE_TYPES.part } From aa2f35e46730bb13d2293e58f1ee774b5e312d7d Mon Sep 17 00:00:00 2001 From: kcar Date: Sun, 7 Jun 2026 03:56:09 +0100 Subject: [PATCH 07/14] feat(exam): render template PDFs behind setup canvas --- src/AppRoutes.admin.test.tsx | 5 +- .../exam/setup/ExamTemplateSetupPage.tsx | 69 +++++++++++++++++-- src/pages/exam/setup/examCanvasShapes.tsx | 30 +++++++- src/pages/exam/setup/pdfLoader.ts | 39 +++++++++++ src/services/exam/examRepository.ts | 9 +++ src/utils/exam-canvas/model.test.ts | 20 +++++- src/utils/exam-canvas/model.ts | 41 ++++++++--- 7 files changed, 192 insertions(+), 21 deletions(-) create mode 100644 src/pages/exam/setup/pdfLoader.ts diff --git a/src/AppRoutes.admin.test.tsx b/src/AppRoutes.admin.test.tsx index a4346e5..627c57f 100644 --- a/src/AppRoutes.admin.test.tsx +++ b/src/AppRoutes.admin.test.tsx @@ -31,7 +31,10 @@ vi.mock('./pages/NotFoundPublic', () => ({ default: () =>
Public Not Found< vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () =>
Public Home
})); vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () =>
Single Player
})); vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () =>
Multiplayer
})); -vi.mock('./pages/exam', () => ({ ExamDashboardPage: () =>
Exam Marker
})); +vi.mock('./pages/exam', () => ({ + ExamDashboardPage: () =>
Exam Marker
, + ExamTemplateSetupPage: () =>
Exam Template Setup
, +})); vi.mock('./pages/user/calendarPage', () => ({ default: () =>
Calendar
})); vi.mock('./pages/user/settingsPage', () => ({ default: () =>
Settings
})); vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () =>
TLDraw Dev
})); diff --git a/src/pages/exam/setup/ExamTemplateSetupPage.tsx b/src/pages/exam/setup/ExamTemplateSetupPage.tsx index e9ad39b..d92e680 100644 --- a/src/pages/exam/setup/ExamTemplateSetupPage.tsx +++ b/src/pages/exam/setup/ExamTemplateSetupPage.tsx @@ -13,8 +13,9 @@ import { ErrorBoundary } from '../../../components/ErrorBoundary' import { logger } from '../../../debugConfig' import { examRepository } from '../../../services/exam/examRepository' import type { ExamTemplateDetail } from '../../../types/exam.types' -import { ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model' -import { examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, shapeTypeToKind } from './examCanvasShapes' +import { CanvasPageGeometry, ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model' +import { PDF_PAGE_SHAPE_TYPE, examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, isPdfPageShape, shapeTypeToKind } from './examCanvasShapes' +import { loadPdfPageImages, PdfPageImage } from './pdfLoader' const TOOLS = [ { id: 'select', label: 'Select', tip: 'Move, resize, or delete shapes.', color: 'inherit' as const }, @@ -28,6 +29,18 @@ const TOOLS = [ { id: SHAPE_TYPES.furniture, label: 'Furniture', tip: 'Mark margins/page numbers/ignored decoration.', color: 'inherit' as const }, ] +const PAGE_START_X = 260 +const PDF_PAGE_IDS_PREFIX = 'pdf-page-' + +function pageGeometryFromImages(pages: PdfPageImage[]): CanvasPageGeometry[] { + let y = 0 + return pages.map((page) => { + const geometry = { pageNumber: page.pageNumber, x: PAGE_START_X, y, w: page.width, h: page.height } + y += page.height + return geometry + }) +} + function apiMessage(err: unknown): { message: string; conflict: boolean } { if (axios.isAxiosError(err)) { const detail = (err.response?.data as { detail?: string } | undefined)?.detail @@ -88,6 +101,26 @@ function loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) { }))) } +function syncPdfPages(editor: Editor, pages: PdfPageImage[]) { + const existing = editor.getCurrentPageShapes().filter((s) => isPdfPageShape(s.type)).map((s) => s.id) + if (existing.length) editor.deleteShapes(existing) + if (!pages.length) return + const geometries = pageGeometryFromImages(pages) + editor.createShapes(geometries.map((geometry) => { + const page = pages[geometry.pageNumber - 1] + return { + id: createShapeId(PDF_PAGE_IDS_PREFIX + geometry.pageNumber), + type: PDF_PAGE_SHAPE_TYPE, + x: geometry.x, + y: geometry.y, + isLocked: true, + props: { w: geometry.w, h: geometry.h, src: page.src, pageNumber: geometry.pageNumber }, + } as any + })) + const ids = geometries.map((geometry) => createShapeId(PDF_PAGE_IDS_PREFIX + geometry.pageNumber)) + try { editor.sendToBack(ids as any) } catch { /* tldraw 3 keeps creation order behind later region shapes */ } +} + function seedGuide(editor: Editor) { const current = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)) if (current.length) return @@ -104,6 +137,7 @@ const ExamTemplateSetupInner: React.FC = () => { const navigate = useNavigate() const theme = useTheme() const editorRef = useRef(null) + const pageGeometriesRef = useRef([]) const [template, setTemplate] = useState(null) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) @@ -111,6 +145,8 @@ const ExamTemplateSetupInner: React.FC = () => { const [error, setError] = useState(null) const [conflict, setConflict] = useState(null) const [activeTool, setActiveTool] = useState('select') + const [pdfStatus, setPdfStatus] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading') + const [pdfError, setPdfError] = useState(null) const load = useCallback(async () => { if (!templateId) return @@ -118,8 +154,26 @@ const ExamTemplateSetupInner: React.FC = () => { try { const detail = await examRepository.getTemplate(templateId) setTemplate(detail) + let pages: PdfPageImage[] = [] + setPdfStatus('loading') + setPdfError(null) + try { + const bytes = await examRepository.getTemplateSourcePdf(templateId) + pages = await loadPdfPageImages(bytes) + setPdfStatus(pages.length ? 'ready' : 'missing') + } catch (pdfErr) { + const pdfMsg = apiMessage(pdfErr).message + setPdfStatus(pdfMsg.toLowerCase().includes('404') ? 'missing' : 'error') + setPdfError(pdfMsg) + logger.warn('cc-exam-marker', 'Template source PDF load failed', { templateId, message: pdfMsg }) + } + const geometries = pageGeometryFromImages(pages) + pageGeometriesRef.current = geometries const editor = editorRef.current - if (editor) loadShapes(editor, shapesFromTemplate(detail)) + if (editor) { + syncPdfPages(editor, pages) + loadShapes(editor, shapesFromTemplate(detail, geometries)) + } setDirty(false) } catch (e) { const msg = apiMessage(e).message @@ -139,10 +193,10 @@ const ExamTemplateSetupInner: React.FC = () => { try { ensureDomainIds(editor) const shapes = editor.getCurrentPageShapes().map(modelFromTLShape).filter(Boolean) as ExamCanvasShapeModel[] - const payload = serializeCanvasShapes(template, shapes) + const payload = serializeCanvasShapes(template, shapes, pageGeometriesRef.current) const saved = await examRepository.replaceTemplate(templateId, payload) setTemplate(saved) - loadShapes(editor, shapesFromTemplate(saved)) + loadShapes(editor, shapesFromTemplate(saved, pageGeometriesRef.current)) setDirty(false) } catch (e) { const msg = apiMessage(e) @@ -186,7 +240,7 @@ const ExamTemplateSetupInner: React.FC = () => { editorRef.current = editor editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' }) editor.store.listen(() => setDirty(true), { scope: 'document' }) - if (template) loadShapes(editor, shapesFromTemplate(template)); else seedGuide(editor) + if (template) loadShapes(editor, shapesFromTemplate(template, pageGeometriesRef.current)); else seedGuide(editor) }} /> @@ -211,6 +265,9 @@ const ExamTemplateSetupInner: React.FC = () => { 1) Draw top and bottom Boundary lines for each main question. 2) Draw Part boxes inside the pair. 3) Draw Response/Context/metadata regions inside a Part. Save derives parent links by spatial containment and reloads from the API. + + PDF backdrop: {pdfStatus === 'ready' ? 'loaded and locked behind regions' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF for this template' : pdfError ?? 'failed to load'} + {loading && } diff --git a/src/pages/exam/setup/examCanvasShapes.tsx b/src/pages/exam/setup/examCanvasShapes.tsx index bdd6596..50da7be 100644 --- a/src/pages/exam/setup/examCanvasShapes.tsx +++ b/src/pages/exam/setup/examCanvasShapes.tsx @@ -3,6 +3,8 @@ import React from 'react' import { BaseBoxShapeTool, BaseBoxShapeUtil, HTMLContainer, T, TLBaseBoxShape, toDomPrecision } from '@tldraw/tldraw' import type { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model' +export const PDF_PAGE_SHAPE_TYPE = 'exam-pdf-page' + export const SHAPE_TYPES = { boundary: 'exam-boundary', part: 'exam-part', @@ -14,6 +16,11 @@ export const SHAPE_TYPES = { furniture: 'exam-region-furniture', } as const +export type ExamPdfPageTLShape = TLBaseBoxShape & { + type: typeof PDF_PAGE_SHAPE_TYPE + props: { w: number; h: number; src: string; pageNumber: number } +} + export type ExamCanvasTLShape = TLBaseBoxShape & { type: typeof SHAPE_TYPES[keyof typeof SHAPE_TYPES] props: { @@ -68,7 +75,22 @@ function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) { } const sharedProps = { w: T.number, h: T.number, label: T.string, kind: T.string, maxMarks: T.optional(T.number), responseForm: T.optional(T.string), contextType: T.optional(T.string), questionId: T.optional(T.string), domainId: T.optional(T.string) } -const ind = (s: ExamCanvasTLShape) => +const ind = (s: ExamCanvasTLShape | ExamPdfPageTLShape) => + +class PdfPageUtil extends BaseBoxShapeUtil { + static override type = PDF_PAGE_SHAPE_TYPE + static override props = { w: T.number, h: T.number, src: T.string, pageNumber: T.number } + override getDefaultProps() { return { w: 780, h: 1100, src: '', pageNumber: 1 } } + override canEdit() { return false } + override component(shape: ExamPdfPageTLShape) { + return ( + + {'PDF + + ) + } + override indicator(shape: ExamPdfPageTLShape) { return ind(shape) } +} class BoundaryUtil extends BaseBoxShapeUtil { static override type = SHAPE_TYPES.boundary; static override props = sharedProps; override getDefaultProps(){ return defaultProps('boundary', 680, 8) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } } class PartUtil extends BaseBoxShapeUtil { static override type = SHAPE_TYPES.part; static override props = sharedProps; override getDefaultProps(){ return defaultProps('part', 420, 170) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } } function regionUtil(type: string, kind: ExamCanvasRegionKind, w = 360, h = 120) { return class extends BaseBoxShapeUtil { static override type = type; static override props = sharedProps; override getDefaultProps(){ return defaultProps(kind, w, h) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } } } @@ -82,9 +104,13 @@ class MarkAreaTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.m class ReferenceTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.reference; static override initial = 'pointing'; shapeType = SHAPE_TYPES.reference } class FurnitureTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.furniture; static override initial = 'pointing'; shapeType = SHAPE_TYPES.furniture } -export const examCanvasShapeUtils = [BoundaryUtil, PartUtil, regionUtil(SHAPE_TYPES.response, 'response'), regionUtil(SHAPE_TYPES.context, 'context'), regionUtil(SHAPE_TYPES.question_number, 'question_number', 170, 80), regionUtil(SHAPE_TYPES.mark_area, 'mark_area', 170, 80), regionUtil(SHAPE_TYPES.reference, 'reference'), regionUtil(SHAPE_TYPES.furniture, 'furniture')] as const +export const examCanvasShapeUtils = [PdfPageUtil, BoundaryUtil, PartUtil, regionUtil(SHAPE_TYPES.response, 'response'), regionUtil(SHAPE_TYPES.context, 'context'), regionUtil(SHAPE_TYPES.question_number, 'question_number', 170, 80), regionUtil(SHAPE_TYPES.mark_area, 'mark_area', 170, 80), regionUtil(SHAPE_TYPES.reference, 'reference'), regionUtil(SHAPE_TYPES.furniture, 'furniture')] as const export const examCanvasTools = [BoundaryTool, PartTool, ResponseTool, ContextTool, QuestionNumberTool, MarkAreaTool, ReferenceTool, FurnitureTool] as const +export function isPdfPageShape(type: string): boolean { + return type === PDF_PAGE_SHAPE_TYPE +} + export function shapeTypeToKind(type: string): ExamCanvasShapeKind | null { const entry = Object.entries(SHAPE_TYPES).find(([, v]) => v === type) return (entry?.[0] as ExamCanvasShapeKind | undefined) ?? null diff --git a/src/pages/exam/setup/pdfLoader.ts b/src/pages/exam/setup/pdfLoader.ts new file mode 100644 index 0000000..3f7baa8 --- /dev/null +++ b/src/pages/exam/setup/pdfLoader.ts @@ -0,0 +1,39 @@ +import * as pdfjsLib from "pdfjs-dist" +import pdfWorkerSrc from "pdfjs-dist/build/pdf.worker.mjs?url" + +import { PAGE_WIDTH } from "../../../utils/exam-canvas/model" + +pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerSrc + +export interface PdfPageImage { + pageNumber: number + src: string + width: number + height: number +} + +export async function loadPdfPageImages(pdfBytes: ArrayBuffer, targetWidth = PAGE_WIDTH): Promise { + const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(pdfBytes) }).promise + const pages: PdfPageImage[] = [] + + for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) { + const page = await pdf.getPage(pageNumber) + const baseViewport = page.getViewport({ scale: 1 }) + const scale = targetWidth / baseViewport.width + const viewport = page.getViewport({ scale }) + const canvas = document.createElement("canvas") + canvas.width = Math.ceil(viewport.width) + canvas.height = Math.ceil(viewport.height) + const context = canvas.getContext("2d") + if (!context) throw new Error("Unable to create PDF render canvas") + await page.render({ canvasContext: context, viewport }).promise + pages.push({ + pageNumber, + src: canvas.toDataURL("image/png"), + width: canvas.width, + height: canvas.height, + }) + } + + return pages +} diff --git a/src/services/exam/examRepository.ts b/src/services/exam/examRepository.ts index e4c0913..a42cdde 100644 --- a/src/services/exam/examRepository.ts +++ b/src/services/exam/examRepository.ts @@ -127,6 +127,15 @@ export const examRepository = { return res.data; }, + async getTemplateSourcePdf(templateId: string): Promise { + const headers = await authHeaders(); + const res = await axios.get(`${EXAM_BASE}/templates/${templateId}/source-pdf`, { + headers, + responseType: 'arraybuffer', + }); + return res.data; + }, + async createTemplate(payload: CreateTemplatePayload): Promise { const headers = await authHeaders(); const res = await axios.post(`${EXAM_BASE}/templates`, payload, { headers }); diff --git a/src/utils/exam-canvas/model.test.ts b/src/utils/exam-canvas/model.test.ts index 2142dc9..52b40b7 100644 --- a/src/utils/exam-canvas/model.test.ts +++ b/src/utils/exam-canvas/model.test.ts @@ -1,7 +1,6 @@ - import { describe, expect, it } from 'vitest' import type { ExamTemplateDetail } from '../../types/exam.types' -import { isUuid, serializeCanvasShapes, shapesFromTemplate } from './model' +import { isUuid, pageForY, serializeCanvasShapes, shapesFromTemplate } from './model' const template: ExamTemplateDetail = { id: 'tpl-1', title: 'Physics', subject: 'Physics', exam_id: null, exam_code: null, source_file_id: null, page_count: 1, @@ -30,6 +29,23 @@ describe('exam setup canvas serialization', () => { expect(payload.boundaries.every((b) => isUuid(b.id))).toBe(true) }) + it('maps shapes to the visible PDF page geometry rather than a fixed page height', () => { + const pages = [ + { pageNumber: 1, x: 260, y: 0, w: 780, h: 1000 }, + { pageNumber: 2, x: 260, y: 1000, w: 780, h: 1200 }, + ] + expect(pageForY(1050, pages)).toBe(2) + const payload = serializeCanvasShapes(template, [ + { id: 'b-top', kind: 'boundary', x: 260, y: 1020, w: 700, h: 8, label: 'Q1 start' }, + { id: 'b-bottom', kind: 'boundary', x: 260, y: 1700, w: 700, h: 8, label: 'Q1 end' }, + { id: 'part-1', kind: 'part', x: 300, y: 1120, w: 300, h: 160, label: 'Q1(a)' }, + { id: 'resp-1', kind: 'response', x: 320, y: 1160, w: 240, h: 80 }, + ], pages) + expect(payload.questions.find((q) => !q.is_container)?.page).toBe(2) + expect(payload.boundaries.every((b) => b.page_index === 1)).toBe(true) + expect(payload.response_areas[0].page).toBe(2) + }) + it('round-trips saved Part geometry and all S4-9 region kinds from API detail', () => { const shapes = shapesFromTemplate({ ...template, diff --git a/src/utils/exam-canvas/model.ts b/src/utils/exam-canvas/model.ts index 00ea3ae..22bdb54 100644 --- a/src/utils/exam-canvas/model.ts +++ b/src/utils/exam-canvas/model.ts @@ -3,6 +3,9 @@ import type { ExamTemplateDetail, TemplateReplacePayload } from '../../types/exa export const PAGE_HEIGHT = 1100 export const PAGE_WIDTH = 780 +export const PAGE_GAP = 0 + +export interface CanvasPageGeometry { pageNumber: number; x: number; y: number; w: number; h: number } export type ExamCanvasRegionKind = 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture' export type ExamCanvasShapeKind = 'boundary' | 'part' | ExamCanvasRegionKind @@ -25,10 +28,28 @@ export interface ExamCanvasShapeModel { questionId?: string | null } -export function pageForY(y: number): number { +export function pageForY(y: number, pages?: CanvasPageGeometry[]): number { + if (pages?.length) { + const hit = pages.find((page) => y >= page.y && y <= page.y + page.h) + if (hit) return hit.pageNumber + const nearest = pages.reduce((best, page) => { + const dy = Math.min(Math.abs(y - page.y), Math.abs(y - (page.y + page.h))) + return dy < best.dy ? { page, dy } : best + }, { page: pages[0], dy: Number.POSITIVE_INFINITY }) + return nearest.page.pageNumber + } return Math.max(1, Math.floor(y / PAGE_HEIGHT) + 1) } +export function pageTop(page: number, pages?: CanvasPageGeometry[]): number { + const hit = pages?.find((p) => p.pageNumber === page) + return hit?.y ?? ((page - 1) * (PAGE_HEIGHT + PAGE_GAP)) +} + +function pageForShape(shape: Pick, pages?: CanvasPageGeometry[]): number { + return pageForY(shape.y + shape.h / 2, pages) +} + export function isUuid(value: string | null | undefined): value is string { return typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value) } @@ -63,10 +84,10 @@ function bandContains(top: ExamCanvasShapeModel, bottom: ExamCanvasShapeModel, s return cy >= minY && cy <= maxY } -export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: ExamCanvasShapeModel[]): TemplateReplacePayload { +export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: ExamCanvasShapeModel[], pages?: CanvasPageGeometry[]): TemplateReplacePayload { const orderedBoundaries = shapes .filter((s) => s.kind === 'boundary') - .sort((a, b) => (pageForY(a.y) - pageForY(b.y)) || (a.y - b.y)) + .sort((a, b) => (pageForShape(a, pages) - pageForShape(b, pages)) || (a.y - b.y)) const parts = shapes.filter((s) => s.kind === 'part') const regions = shapes.filter((s) => s.kind !== 'boundary' && s.kind !== 'part') @@ -84,7 +105,7 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam questions.push({ id: questionId, label, order: qNum - 1, max_marks: 0, is_container: true, mark_scheme: {} }) bands.push({ questionId, top, bottom }) for (const b of [top, bottom]) { - boundaries.push({ id: isUuid(b.id) ? b.id : newDomainId(), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForY(b.y) - 1, y: b.y, bounds: bounds(b), source: 'manual', confirmed: true }) + boundaries.push({ id: isUuid(b.id) ? b.id : newDomainId(), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForShape(b, pages) - 1, y: b.y, bounds: bounds(b), source: 'manual', confirmed: true }) } } @@ -93,23 +114,23 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam const parentBand = bands.find((band) => bandContains(band.top, band.bottom, part)) const qid = isUuid(part.questionId) ? part.questionId : isUuid(part.id) ? part.id : newDomainId() partQuestionIds.set(part.id, qid) - questions.push({ id: qid, parent_id: parentBand?.questionId ?? null, label: part.label || `Part ${index + 1}`, order: index, max_marks: Number(part.maxMarks ?? 0), answer_type: part.answerType ?? 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: bounds(part), page: pageForY(part.y) }) + questions.push({ id: qid, parent_id: parentBand?.questionId ?? null, label: part.label || `Part ${index + 1}`, order: index, max_marks: Number(part.maxMarks ?? 0), answer_type: part.answerType ?? 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: bounds(part), page: pageForShape(part, pages) }) }) const response_areas: TemplateReplacePayload['response_areas'] = [] for (const region of regions) { const containingPart = parts.find((part) => contains(bounds(part), bounds(region))) - const fallbackPart = parts.find((part) => pageForY(part.y) === pageForY(region.y)) ?? parts[0] + const fallbackPart = parts.find((part) => pageForShape(part, pages) === pageForShape(region, pages)) ?? parts[0] const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined if (!questionId) continue const kind = region.kind as ExamCanvasRegionKind - response_areas.push({ id: isUuid(region.id) ? region.id : newDomainId(), question_id: questionId, page: pageForY(region.y), bounds: bounds(region), kind, response_form: kind === 'response' ? (region.responseForm ?? 'lines') : null, context_type: kind === 'context' ? (region.contextType ?? 'generic') : null, source: 'manual', confirmed: true, confidence: null }) + response_areas.push({ id: isUuid(region.id) ? region.id : newDomainId(), question_id: questionId, page: pageForShape(region, pages), bounds: bounds(region), kind, response_form: kind === 'response' ? (region.responseForm ?? 'lines') : null, context_type: kind === 'context' ? (region.contextType ?? 'generic') : null, source: 'manual', confirmed: true, confidence: null }) } return { meta: { title: template.title, subject: template.subject ?? undefined, page_count: template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries } } -export function shapesFromTemplate(detail: ExamTemplateDetail): ExamCanvasShapeModel[] { +export function shapesFromTemplate(detail: ExamTemplateDetail, pages?: CanvasPageGeometry[]): ExamCanvasShapeModel[] { const shapes: ExamCanvasShapeModel[] = [] const questions = new Map(detail.questions.map((q) => [q.id, q])) for (const b of detail.boundaries ?? []) { @@ -121,9 +142,9 @@ export function shapesFromTemplate(detail: ExamTemplateDetail): ExamCanvasShapeM shapes.push({ id: q.id, kind: 'part', x: Number(q.bounds.x ?? 80), y: Number(q.bounds.y ?? 120), w: Number(q.bounds.w ?? 420), h: Number(q.bounds.h ?? 180), label: q.label, maxMarks: q.max_marks, answerType: q.answer_type ?? 'written', questionId: q.id }) } for (const r of detail.response_areas ?? []) { - const bb = r.bounds ?? { x: 100, y: (r.page - 1) * PAGE_HEIGHT + 360, w: 360, h: 120 } + const bb = r.bounds ?? { x: 100, y: pageTop(r.page, pages) + 360, w: 360, h: 120 } const q = questions.get(r.question_id) - shapes.push({ id: r.id, kind: r.kind, x: Number(bb.x ?? 100), y: Number(bb.y ?? (r.page - 1) * PAGE_HEIGHT + 360), w: Number(bb.w ?? 360), h: Number(bb.h ?? 120), label: q ? `→ ${q.label}` : r.kind, responseForm: r.response_form ?? undefined, contextType: r.context_type ?? undefined, questionId: r.question_id }) + shapes.push({ id: r.id, kind: r.kind, x: Number(bb.x ?? 100), y: Number(bb.y ?? pageTop(r.page, pages) + 360), w: Number(bb.w ?? 360), h: Number(bb.h ?? 120), label: q ? `→ ${q.label}` : r.kind, responseForm: r.response_form ?? undefined, contextType: r.context_type ?? undefined, questionId: r.question_id }) } return shapes } From 2de3e291797588788a995d76e512795f73b824a3 Mon Sep 17 00:00:00 2001 From: CC Worker Date: Sun, 7 Jun 2026 03:21:44 +0000 Subject: [PATCH 08/14] fix: serve .mjs files as application/javascript for pdfjs module worker nginx:alpine mime.types only covers .js, not .mjs. The pdfjs-dist v4 worker is output as pdf.worker-*.mjs; without the correct MIME type the browser refuses to execute it as a module worker and pdfjs throws 'Network Error', blocking the PDF backdrop from rendering. Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Dockerfile b/Dockerfile index 0a5c0ba..7f87db7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,9 @@ FROM nginx:alpine # Copy built files COPY --from=builder /app/dist /usr/share/nginx/html +# .mjs files (pdfjs worker) must be served as application/javascript for module workers +RUN sed -i 's|application/javascript\s*js;|application/javascript js mjs;|' /etc/nginx/mime.types + # Create a simple nginx configuration RUN echo 'server { \ listen 3000; \ From 8e8a345e618b955d40d9df9575f12a97e5f910ff Mon Sep 17 00:00:00 2001 From: CC Worker Date: Sun, 7 Jun 2026 03:35:09 +0000 Subject: [PATCH 09/14] fix: incremental PDF page rendering for exam setup backdrop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rendering a 36-page AQA exam PDF sequentially created 36 large canvas elements, causing memory pressure and keeping pdfStatus='loading' for 60–120s in headless Chrome. Two changes: 1. pdfLoader.ts: reuse a single canvas (reduces peak memory from ~120MB to ~4MB) and fire onPageReady callback after each page so callers can stream pages to the canvas as they render. 2. ExamTemplateSetupPage.tsx: use the callback to add each PDF page shape to the tldraw canvas the moment it renders, making the first page visible within a few seconds rather than after all pages load. Co-Authored-By: Claude Sonnet 4.6 --- src/pages/exam/setup/ExamTemplateSetupPage.tsx | 16 +++++++++++++++- src/pages/exam/setup/pdfLoader.ts | 12 ++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/pages/exam/setup/ExamTemplateSetupPage.tsx b/src/pages/exam/setup/ExamTemplateSetupPage.tsx index d92e680..21f9b82 100644 --- a/src/pages/exam/setup/ExamTemplateSetupPage.tsx +++ b/src/pages/exam/setup/ExamTemplateSetupPage.tsx @@ -159,7 +159,21 @@ const ExamTemplateSetupInner: React.FC = () => { setPdfError(null) try { const bytes = await examRepository.getTemplateSourcePdf(templateId) - pages = await loadPdfPageImages(bytes) + pages = await loadPdfPageImages(bytes, undefined, (partialPages) => { + const newPage = partialPages[partialPages.length - 1] + const allGeometries = pageGeometryFromImages(partialPages) + pageGeometriesRef.current = allGeometries + const ed = editorRef.current + if (ed) { + const geometry = allGeometries[partialPages.length - 1] + const shapeId = createShapeId(PDF_PAGE_IDS_PREFIX + newPage.pageNumber) + if (!ed.getCurrentPageShapes().find((s) => s.id === shapeId)) { + ed.createShapes([{ id: shapeId, type: PDF_PAGE_SHAPE_TYPE, x: geometry.x, y: geometry.y, isLocked: true, props: { w: geometry.w, h: geometry.h, src: newPage.src, pageNumber: newPage.pageNumber } } as any]) + try { ed.sendToBack([shapeId as any]) } catch { /* */ } + } + } + setPdfStatus('ready') + }) setPdfStatus(pages.length ? 'ready' : 'missing') } catch (pdfErr) { const pdfMsg = apiMessage(pdfErr).message diff --git a/src/pages/exam/setup/pdfLoader.ts b/src/pages/exam/setup/pdfLoader.ts index 3f7baa8..4e3bfae 100644 --- a/src/pages/exam/setup/pdfLoader.ts +++ b/src/pages/exam/setup/pdfLoader.ts @@ -12,20 +12,27 @@ export interface PdfPageImage { height: number } -export async function loadPdfPageImages(pdfBytes: ArrayBuffer, targetWidth = PAGE_WIDTH): Promise { +export async function loadPdfPageImages( + pdfBytes: ArrayBuffer, + targetWidth = PAGE_WIDTH, + onPageReady?: (pages: PdfPageImage[]) => void, +): Promise { const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(pdfBytes) }).promise const pages: PdfPageImage[] = [] + // Reuse a single canvas across all pages to avoid allocating ~120 MB of canvas memory + // for a typical 36-page exam paper. + const canvas = document.createElement("canvas") for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) { const page = await pdf.getPage(pageNumber) const baseViewport = page.getViewport({ scale: 1 }) const scale = targetWidth / baseViewport.width const viewport = page.getViewport({ scale }) - const canvas = document.createElement("canvas") canvas.width = Math.ceil(viewport.width) canvas.height = Math.ceil(viewport.height) const context = canvas.getContext("2d") if (!context) throw new Error("Unable to create PDF render canvas") + context.clearRect(0, 0, canvas.width, canvas.height) await page.render({ canvasContext: context, viewport }).promise pages.push({ pageNumber, @@ -33,6 +40,7 @@ export async function loadPdfPageImages(pdfBytes: ArrayBuffer, targetWidth = PAG width: canvas.width, height: canvas.height, }) + onPageReady?.([...pages]) } return pages From 3eac792cedee3a1823ec680205cb6555a53e9392 Mon Sep 17 00:00:00 2001 From: kcar Date: Sun, 7 Jun 2026 05:10:11 +0100 Subject: [PATCH 10/14] =?UTF-8?q?feat(exam-setup):=20UX=20polish=20?= =?UTF-8?q?=E2=80=94=20icons,=20dark/light=20palette=20tokens,=20multi-pag?= =?UTF-8?q?e=20boundary=20hint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exam/setup/ExamTemplateSetupPage.tsx | 43 +++++++----- src/pages/exam/setup/examCanvasShapes.tsx | 70 +++++++++++++------ 2 files changed, 77 insertions(+), 36 deletions(-) diff --git a/src/pages/exam/setup/ExamTemplateSetupPage.tsx b/src/pages/exam/setup/ExamTemplateSetupPage.tsx index 21f9b82..7f020b5 100644 --- a/src/pages/exam/setup/ExamTemplateSetupPage.tsx +++ b/src/pages/exam/setup/ExamTemplateSetupPage.tsx @@ -14,19 +14,19 @@ import { logger } from '../../../debugConfig' import { examRepository } from '../../../services/exam/examRepository' import type { ExamTemplateDetail } from '../../../types/exam.types' import { CanvasPageGeometry, ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model' -import { PDF_PAGE_SHAPE_TYPE, examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, isPdfPageShape, shapeTypeToKind } from './examCanvasShapes' +import { PDF_PAGE_SHAPE_TYPE, canvasShapePalette, examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, isPdfPageShape, shapeTypeToKind } from './examCanvasShapes' import { loadPdfPageImages, PdfPageImage } from './pdfLoader' const TOOLS = [ - { id: 'select', label: 'Select', tip: 'Move, resize, or delete shapes.', color: 'inherit' as const }, - { id: SHAPE_TYPES.boundary, label: 'Boundary', tip: 'Draw one horizontal line. A main question is saved from each top+bottom pair.', color: 'error' as const }, - { id: SHAPE_TYPES.part, label: 'Part', tip: 'Draw the markable sub-question box inside a boundary pair.', color: 'warning' as const }, - { id: SHAPE_TYPES.response, label: 'Response', tip: 'Draw around where the student writes; saved with response_form=lines.', color: 'primary' as const }, - { id: SHAPE_TYPES.context, label: 'Context', tip: 'Draw around stimulus/context material; saved with context_type=generic.', color: 'secondary' as const }, - { id: SHAPE_TYPES.question_number, label: 'Q Number', tip: 'Box the printed question number.', color: 'success' as const }, - { id: SHAPE_TYPES.mark_area, label: 'Mark Area', tip: 'Box printed marks such as [2].', color: 'success' as const }, - { id: SHAPE_TYPES.reference, label: 'Reference', tip: 'Box student resources/reference material.', color: 'info' as const }, - { id: SHAPE_TYPES.furniture, label: 'Furniture', tip: 'Mark margins/page numbers/ignored decoration.', color: 'inherit' as const }, + { id: 'select', label: 'Select', icon: '↖', tip: 'Move, resize, delete, or inspect the Attached pill on a region.', color: 'inherit' as const }, + { id: SHAPE_TYPES.boundary, label: 'Boundary', icon: canvasShapePalette.boundary.icon, tip: 'Draw Q start and Q end horizontal rules; an end rule on a later page creates a multi-page question span.', color: 'error' as const }, + { id: SHAPE_TYPES.part, label: 'Part', icon: canvasShapePalette.part.icon, tip: 'Draw the markable sub-question box inside a boundary pair; it becomes the leaf question/part.', color: 'warning' as const }, + { id: SHAPE_TYPES.response, label: 'Response', icon: canvasShapePalette.response.icon, tip: 'Draw around where the student writes; blue regions save as response areas.', color: 'primary' as const }, + { id: SHAPE_TYPES.context, label: 'Context', icon: canvasShapePalette.context.icon, tip: 'Draw stimulus, figures, tables, or prompt text; purple dashed regions save as context.', color: 'secondary' as const }, + { id: SHAPE_TYPES.question_number, label: 'Q Number', icon: canvasShapePalette.question_number.icon, tip: 'Box the printed question number for OCR/structure extraction.', color: 'success' as const }, + { id: SHAPE_TYPES.mark_area, label: 'Mark Area', icon: canvasShapePalette.mark_area.icon, tip: 'Box printed marks such as [2] or Total for Question text.', color: 'success' as const }, + { id: SHAPE_TYPES.reference, label: 'Reference', icon: canvasShapePalette.reference.icon, tip: 'Box formulae, data sheets, appendices, or other resources the student may use.', color: 'info' as const }, + { id: SHAPE_TYPES.furniture, label: 'Furniture', icon: canvasShapePalette.furniture.icon, tip: 'Mark page numbers, margins, blank space, or decoration to exclude from extraction.', color: 'inherit' as const }, ] const PAGE_START_X = 260 @@ -126,9 +126,10 @@ function seedGuide(editor: Editor) { if (current.length) return editor.createShapes([ { id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: 120, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', label: 'Q1 start', domainId: newDomainId() } }, - { id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: 520, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', label: 'Q1 end', domainId: newDomainId() } }, + { id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: PAGE_HEIGHT + 160, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', label: 'Q1 end (page 2)', domainId: newDomainId() } }, { id: createShapeId(newDomainId()), type: SHAPE_TYPES.part, x: 92, y: 180, props: { w: 520, h: 150, kind: 'part', label: 'Q1(a)', maxMarks: 3, domainId: newDomainId() } }, { id: createShapeId(newDomainId()), type: SHAPE_TYPES.response, x: 116, y: 355, props: { w: 470, h: 120, kind: 'response', label: 'Response', responseForm: 'lines', domainId: newDomainId() } }, + { id: createShapeId(newDomainId()), type: SHAPE_TYPES.context, x: 116, y: 495, props: { w: 470, h: 90, kind: 'context', label: 'Context', contextType: 'generic', domainId: newDomainId() } }, ]) } @@ -227,7 +228,7 @@ const ExamTemplateSetupInner: React.FC = () => { size="small" variant={activeTool === tool.id ? 'contained' : 'outlined'} color={tool.color} - startIcon={tool.id === 'select' ? : undefined} + startIcon={tool.id === 'select' ? : {tool.icon}} onClick={() => { const editor = editorRef.current if (!editor) return @@ -264,7 +265,7 @@ const ExamTemplateSetupInner: React.FC = () => { {template?.title ?? 'Template setup'} - Exam Marker › Setup · draw boundaries, part boxes, and regions; Save persists a full replace. + Exam Marker › Setup · coloured tools map to persisted regions; boundary start/end pairs can span pages. @@ -274,11 +275,21 @@ const ExamTemplateSetupInner: React.FC = () => { {toolButtons} - + Setup guide - - 1) Draw top and bottom Boundary lines for each main question. 2) Draw Part boxes inside the pair. 3) Draw Response/Context/metadata regions inside a Part. Save derives parent links by spatial containment and reloads from the API. + + 1) Boundary start/end lines define each main question. 2) Draw amber Part boxes for markable sub-questions. 3) Draw coloured Response, Context, Q Number, Mark Area, Reference, and Furniture regions; Save derives parent links by containment. + + {(['boundary', 'part', 'response', 'context', 'question_number', 'mark_area', 'reference', 'furniture'] as const).map((kind) => { + const p = canvasShapePalette[kind] + return + })} + + + Multi-page boundary pairing + Draw “Q start” on page N, then “Q end” on a later page; save pairs boundaries by reading order into one question span. + Open design choices resolved for v1: labels use “Q start/end”; persistent Attached pills confirm containment; rectangles stay simple for dense multi-column papers; Back button remains explicit. PDF backdrop: {pdfStatus === 'ready' ? 'loaded and locked behind regions' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF for this template' : pdfError ?? 'failed to load'} diff --git a/src/pages/exam/setup/examCanvasShapes.tsx b/src/pages/exam/setup/examCanvasShapes.tsx index 50da7be..a58b748 100644 --- a/src/pages/exam/setup/examCanvasShapes.tsx +++ b/src/pages/exam/setup/examCanvasShapes.tsx @@ -36,41 +36,71 @@ export type ExamCanvasTLShape = TLBaseBoxShape & { } } -const palette: Record = { - boundary: { stroke: '#ef4444', fill: 'rgba(239,68,68,0.06)', dash: '8 6', label: 'Boundary' }, - part: { stroke: '#f59e0b', fill: 'rgba(245,158,11,0.16)', label: 'Part' }, - response: { stroke: '#2563eb', fill: 'rgba(37,99,235,0.16)', label: 'Response' }, - context: { stroke: '#7c3aed', fill: 'rgba(124,58,237,0.14)', dash: '6 5', label: 'Context' }, - question_number: { stroke: '#0f766e', fill: 'rgba(15,118,110,0.14)', label: 'Question #' }, - mark_area: { stroke: '#16a34a', fill: 'rgba(22,163,74,0.14)', label: 'Marks' }, - reference: { stroke: '#0891b2', fill: 'rgba(8,145,178,0.14)', label: 'Reference' }, - furniture: { stroke: '#64748b', fill: 'rgba(100,116,139,0.12)', dash: '3 5', label: 'Furniture' }, +type CanvasPaletteEntry = { + stroke: string + fill: string + darkStroke: string + darkFill: string + dash?: string + label: string + icon: string + role: string } +export const canvasShapePalette: Record = { + boundary: { stroke: '#ef4444', fill: 'rgba(239,68,68,0.06)', darkStroke: '#f87171', darkFill: 'rgba(248,113,113,0.10)', dash: '8 6', label: 'Boundary', icon: '↕', role: 'start/end rule' }, + part: { stroke: '#f59e0b', fill: 'rgba(245,158,11,0.18)', darkStroke: '#fbbf24', darkFill: 'rgba(251,191,36,0.26)', label: 'Part', icon: '□', role: 'markable box' }, + response: { stroke: '#2563eb', fill: 'rgba(37,99,235,0.18)', darkStroke: '#60a5fa', darkFill: 'rgba(96,165,250,0.34)', label: 'Response', icon: '✎', role: 'student writing' }, + context: { stroke: '#7c3aed', fill: 'rgba(124,58,237,0.14)', darkStroke: '#a78bfa', darkFill: 'rgba(167,139,250,0.28)', dash: '6 5', label: 'Context', icon: '◉', role: 'stimulus' }, + question_number: { stroke: '#0f766e', fill: 'rgba(15,118,110,0.14)', darkStroke: '#2dd4bf', darkFill: 'rgba(45,212,191,0.24)', label: 'Question #', icon: '#', role: 'printed label' }, + mark_area: { stroke: '#16a34a', fill: 'rgba(22,163,74,0.14)', darkStroke: '#4ade80', darkFill: 'rgba(74,222,128,0.23)', label: 'Marks', icon: '[2]', role: 'printed marks' }, + reference: { stroke: '#0891b2', fill: 'rgba(8,145,178,0.14)', darkStroke: '#22d3ee', darkFill: 'rgba(34,211,238,0.24)', label: 'Reference', icon: '§', role: 'resource' }, + furniture: { stroke: '#64748b', fill: 'rgba(100,116,139,0.12)', darkStroke: '#cbd5e1', darkFill: 'rgba(148,163,184,0.18)', dash: '3 5', label: 'Furniture', icon: '×', role: 'ignore' }, +} + +const shapeCss = ` +.exam-canvas-shape { --exam-stroke: var(--exam-light-stroke); --exam-fill: var(--exam-light-fill); } +[data-color-mode="dark"] .exam-canvas-shape, .tl-theme__dark .exam-canvas-shape { --exam-stroke: var(--exam-dark-stroke); --exam-fill: var(--exam-dark-fill); } +.exam-canvas-shape__pill { background: rgba(255,255,255,.90); color: var(--exam-stroke); box-shadow: 0 1px 4px rgba(15,23,42,.14); } +[data-color-mode="dark"] .exam-canvas-shape__pill, .tl-theme__dark .exam-canvas-shape__pill { background: rgba(15,23,42,.88); color: var(--exam-stroke); box-shadow: 0 1px 5px rgba(0,0,0,.35); } +` + function renderShape(shape: ExamCanvasTLShape) { const kind = shape.props.kind - const p = palette[kind] ?? palette.response + const p = canvasShapePalette[kind] ?? canvasShapePalette.response const isBoundary = kind === 'boundary' return ( -
- + +
+ + {shape.props.label || p.label} - {!isBoundary && shape.props.questionId && Attached} + {!isBoundary && shape.props.questionId && Attached} + {isBoundary && pair across pages}
) } function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) { - const p = palette[kind] + const p = canvasShapePalette[kind] return { w, h, label: p.label, kind, responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined } } From 3389fdcb5b2e56e22b89c4725bd094d4f61ecd0e Mon Sep 17 00:00:00 2001 From: CC Worker Date: Sun, 7 Jun 2026 04:35:34 +0000 Subject: [PATCH 11/14] feat(exam): S4-11 marking flow, results table, CSV, ResultsWidget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /exam-marker/:batchId/mark — ExamMarkingPage: student queue, per-part mark entry, upsert marks - /exam-marker/:batchId/results — ExamResultsPage: results table with absent rows, CSV download - ResultsWidget on ClassDetailPage: last batch summary, class average, absent count, batch creation - New types: MarkingBatch, StudentSubmission, BatchQueueResponse, BatchResultsResponse, MarkUpsertPayload - New repo methods: createBatch, listBatches, getBatchQueue, getBatchResults, getBatchCsv, upsertMark Co-Authored-By: Claude Sonnet 4.6 --- src/AppRoutes.admin.test.tsx | 3 + src/AppRoutes.tsx | 4 +- src/pages/exam/ExamMarkingPage.tsx | 233 ++++++++++++++++++++++++ src/pages/exam/ExamResultsPage.tsx | 177 ++++++++++++++++++ src/pages/exam/ResultsWidget.tsx | 169 +++++++++++++++++ src/pages/exam/index.ts | 3 + src/pages/timetable/ClassDetailPage.tsx | 3 + src/services/exam/examRepository.ts | 47 +++++ src/types/exam.types.ts | 65 +++++++ 9 files changed, 703 insertions(+), 1 deletion(-) create mode 100644 src/pages/exam/ExamMarkingPage.tsx create mode 100644 src/pages/exam/ExamResultsPage.tsx create mode 100644 src/pages/exam/ResultsWidget.tsx diff --git a/src/AppRoutes.admin.test.tsx b/src/AppRoutes.admin.test.tsx index 59616e3..12fc485 100644 --- a/src/AppRoutes.admin.test.tsx +++ b/src/AppRoutes.admin.test.tsx @@ -35,6 +35,9 @@ vi.mock('./pages/exam', () => ({ ExamDashboardPage: () =>
Exam Marker
, ExamTemplateSetupPage: () =>
Exam Template Setup
, MarkSchemePage: () =>
Mark Scheme editor
, + ExamMarkingPage: () =>
Exam Marking
, + ExamResultsPage: () =>
Exam Results
, + ResultsWidget: () =>
Results Widget
, })); vi.mock('./pages/user/calendarPage', () => ({ default: () =>
Calendar
})); vi.mock('./pages/user/settingsPage', () => ({ default: () =>
Settings
})); diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index 681a452..fabc000 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -7,7 +7,7 @@ import LoginPage from './pages/auth/loginPage'; import SignupPage from './pages/auth/signupPage'; import SinglePlayerPage from './pages/tldraw/singlePlayerPage'; import MultiplayerUser from './pages/tldraw/multiplayerUser'; -import { ExamDashboardPage, ExamTemplateSetupPage, MarkSchemePage } from './pages/exam'; +import { ExamDashboardPage, ExamMarkingPage, ExamResultsPage, ExamTemplateSetupPage, MarkSchemePage } from './pages/exam'; import { ErrorBoundary } from './components/ErrorBoundary'; import CalendarPage from './pages/user/calendarPage'; import SettingsPage from './pages/user/settingsPage'; @@ -185,6 +185,8 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/src/pages/exam/ExamMarkingPage.tsx b/src/pages/exam/ExamMarkingPage.tsx new file mode 100644 index 0000000..60a17e2 --- /dev/null +++ b/src/pages/exam/ExamMarkingPage.tsx @@ -0,0 +1,233 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { v5 as uuidv5 } from 'uuid'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Container, + Divider, + List, + ListItemButton, + ListItemText, + Stack, + TextField, + Typography, +} from '@mui/material'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import SaveIcon from '@mui/icons-material/Save'; +import TableChartIcon from '@mui/icons-material/TableChart'; + +import { examRepository } from '../../services/exam/examRepository'; +import type { BatchQueueResponse, ExamQuestion, ExamTemplateDetail, StudentSubmission } from '../../types/exam.types'; + +const MARK_NAMESPACE = '3f2dbbeb-9b15-4f99-9b71-8c535f8dc3d0'; + +function stableMarkId(batchId: string, submissionId: string, questionId: string) { + return uuidv5(`${batchId}:${submissionId}:${questionId}`, MARK_NAMESPACE); +} + +const ExamMarkingPage: React.FC = () => { + const { batchId } = useParams<{ batchId: string }>(); + const navigate = useNavigate(); + const [queue, setQueue] = useState(null); + const [template, setTemplate] = useState(null); + const [selectedId, setSelectedId] = useState(null); + const [marks, setMarks] = useState>({}); + const [comments, setComments] = useState>({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + if (!batchId) return; + setLoading(true); + setError(null); + try { + const nextQueue = await examRepository.getBatchQueue(batchId); + setQueue(nextQueue); + setTemplate(await examRepository.getTemplate(nextQueue.batch.template_id)); + setSelectedId((current) => current ?? nextQueue.submissions[0]?.id ?? null); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, [batchId]); + + useEffect(() => { + void load(); + }, [load]); + + const markableQuestions = useMemo( + () => (template?.questions ?? []).filter((q) => !q.is_container).sort((a, b) => a.order - b.order), + [template], + ); + const selected = queue?.submissions.find((s) => s.id === selectedId) ?? null; + + const saveSelected = async () => { + if (!batchId || !selected) return; + setSaving(true); + setError(null); + setMessage(null); + try { + const writes = markableQuestions + .map((q) => ({ q, raw: marks[q.id], comment: comments[q.id] })) + .filter(({ raw, comment }) => raw !== undefined && raw !== '' || !!comment?.trim()); + for (const { q, raw, comment } of writes) { + const awarded = raw === undefined || raw === '' ? 0 : Number(raw); + if (Number.isNaN(awarded) || awarded < 0 || awarded > (q.max_marks ?? Number.MAX_SAFE_INTEGER)) { + throw new Error(`Invalid mark for ${q.label}`); + } + await examRepository.upsertMark(stableMarkId(batchId, selected.id, q.id), { + submission_id: selected.id, + question_id: q.id, + awarded_marks: awarded, + comment: comment?.trim() || undefined, + confirmed: true, + }); + } + setMessage(`Saved ${writes.length} mark${writes.length === 1 ? '' : 's'} for ${selected.student_name || selected.student_id || 'student'}.`); + setMarks({}); + setComments({}); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(false); + } + }; + + const chooseStudent = (submission: StudentSubmission) => { + setSelectedId(submission.id); + setMarks({}); + setComments({}); + setMessage(null); + }; + + if (loading) { + return ; + } + + if (error && !queue) { + return ( + + {error} + + + ); + } + + return ( + + + + + + {queue?.batch.title || 'Mark exam'} + + {template?.title || queue?.batch.template_id} · {queue?.progress.total ?? 0} students in queue + + + + + + {error && setError(null)}>{error}} + {message && setMessage(null)}>{message}} + {markableQuestions.length === 0 && ( + + This template has no markable parts yet. Add parts in template setup before entering marks. + + )} + + + + + Marking queue + + + + + + + {(queue?.submissions ?? []).map((submission) => ( + chooseStudent(submission)} + sx={{ borderRadius: 1, mb: 0.5 }} + > + + + + ))} + + + + + + + {selected ? ( + + + {selected.student_name || selected.student_id || 'Unknown student'} + Status: {selected.status} + + + {markableQuestions.map((q: ExamQuestion) => ( + + + {q.label} + / {q.max_marks} marks + + setMarks((prev) => ({ ...prev, [q.id]: e.target.value }))} + sx={{ width: { xs: '100%', sm: 120 } }} + /> + setComments((prev) => ({ ...prev, [q.id]: e.target.value }))} + sx={{ flex: 1 }} + /> + + ))} + + + + + + ) : ( + No submissions in this batch. + )} + + + + + + ); +}; + +export default ExamMarkingPage; diff --git a/src/pages/exam/ExamResultsPage.tsx b/src/pages/exam/ExamResultsPage.tsx new file mode 100644 index 0000000..115656c --- /dev/null +++ b/src/pages/exam/ExamResultsPage.tsx @@ -0,0 +1,177 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Container, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@mui/material'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import DownloadIcon from '@mui/icons-material/Download'; +import EditIcon from '@mui/icons-material/Edit'; + +import { examRepository } from '../../services/exam/examRepository'; +import type { BatchResultsResponse } from '../../types/exam.types'; + +function formatMark(value: number | null | undefined) { + return value === null || value === undefined ? '' : String(value); +} + +const ExamResultsPage: React.FC = () => { + const { batchId } = useParams<{ batchId: string }>(); + const navigate = useNavigate(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [downloading, setDownloading] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + if (!batchId) return; + setLoading(true); + setError(null); + try { + setData(await examRepository.getBatchResults(batchId)); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, [batchId]); + + useEffect(() => { + void load(); + }, [load]); + + const summary = useMemo(() => { + const rows = data?.results ?? []; + const presentTotals = rows + .map((r) => r.total) + .filter((v): v is number => typeof v === 'number'); + const average = presentTotals.length + ? presentTotals.reduce((sum, v) => sum + v, 0) / presentTotals.length + : null; + return { + total: rows.length, + absent: rows.filter((r) => r.status === 'absent' && r.total === null).length, + marked: rows.filter((r) => r.total !== null).length, + average, + }; + }, [data]); + + const downloadCsv = async () => { + if (!batchId) return; + setDownloading(true); + setError(null); + try { + const csv = await examRepository.getBatchCsv(batchId); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `batch-${batchId}.csv`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setDownloading(false); + } + }; + + if (loading) { + return ; + } + + if (error || !data) { + return ( + + {error || 'Results not found'} + + + ); + } + + return ( + + + + + + + {data.batch.title || 'Exam results'} + + + Batch {data.batch.id} · created {new Date(data.batch.created_at).toLocaleDateString('en-GB')} + + + + + + + + + + + + + + + + + + + + Student + Status + {data.questions.map((q) => ( + {q.label} / {q.max_marks} + ))} + Total + + + + {data.results.map((row) => ( + + + {row.student_name || row.student_id || 'Unknown student'} + {row.student_id && {row.student_id}} + + + + + {data.questions.map((q) => ( + {formatMark(row.marks[q.id])} + ))} + {formatMark(row.total)} + + ))} + +
+
+
+
+ ); +}; + +export default ExamResultsPage; diff --git a/src/pages/exam/ResultsWidget.tsx b/src/pages/exam/ResultsWidget.tsx new file mode 100644 index 0000000..854900b --- /dev/null +++ b/src/pages/exam/ResultsWidget.tsx @@ -0,0 +1,169 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + MenuItem, + Stack, + TextField, + Typography, +} from '@mui/material'; +import AssessmentIcon from '@mui/icons-material/Assessment'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import TableChartIcon from '@mui/icons-material/TableChart'; + +import { examRepository } from '../../services/exam/examRepository'; +import type { BatchResultsResponse, ExamTemplate, MarkingBatch } from '../../types/exam.types'; + +interface ResultsWidgetProps { + classId: string; + className?: string; +} + +function averageFromResults(results: BatchResultsResponse | null) { + const totals = (results?.results ?? []) + .map((row) => row.total) + .filter((value): value is number => typeof value === 'number'); + if (!totals.length) return null; + return totals.reduce((sum, value) => sum + value, 0) / totals.length; +} + +const ResultsWidget: React.FC = ({ classId, className }) => { + const navigate = useNavigate(); + const [templates, setTemplates] = useState([]); + const [batches, setBatches] = useState([]); + const [latestResults, setLatestResults] = useState(null); + const [selectedTemplateId, setSelectedTemplateId] = useState(''); + const [loading, setLoading] = useState(true); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [nextTemplates, nextBatches] = await Promise.all([ + examRepository.listTemplates(), + examRepository.listBatches(), + ]); + const classBatches = nextBatches + .filter((batch) => batch.class_id === classId) + .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); + setTemplates(nextTemplates); + setSelectedTemplateId((current) => current || nextTemplates[0]?.id || ''); + setBatches(classBatches); + setLatestResults(classBatches[0] ? await examRepository.getBatchResults(classBatches[0].id) : null); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, [classId]); + + useEffect(() => { + void load(); + }, [load]); + + const latestBatch = batches[0] ?? null; + const average = useMemo(() => averageFromResults(latestResults), [latestResults]); + const absent = latestResults?.results.filter((row) => row.status === 'absent' && row.total === null).length ?? 0; + + const createBatch = async () => { + if (!selectedTemplateId) return; + setCreating(true); + setError(null); + try { + const template = templates.find((item) => item.id === selectedTemplateId); + const created = await examRepository.createBatch({ + template_id: selectedTemplateId, + class_id: classId, + title: `${className || 'Class'} · ${template?.title || 'Exam'}`, + }); + navigate(`/exam-marker/${created.id}/mark`); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setCreating(false); + } + }; + + return ( + + + + + + + + Assessment results + Last exam summary for this class + + + {loading && } + + + {error && setError(null)}>{error}} + + {latestBatch ? ( + + + {latestBatch.title || 'Exam batch'} + + {new Date(latestBatch.created_at).toLocaleDateString('en-GB')} + + + + + + + + + + + + + ) : !loading ? ( + + No exam batches have been created for this class yet. + + ) : null} + + + setSelectedTemplateId(e.target.value)} + disabled={!templates.length || creating} + sx={{ minWidth: 260 }} + > + {templates.map((template) => ( + {template.title} + ))} + + + + + + + ); +}; + +export default ResultsWidget; diff --git a/src/pages/exam/index.ts b/src/pages/exam/index.ts index f86afc0..21f10c6 100644 --- a/src/pages/exam/index.ts +++ b/src/pages/exam/index.ts @@ -1,3 +1,6 @@ export { default as ExamDashboardPage } from './ExamDashboardPage'; export { default as ExamTemplateSetupPage } from './setup/ExamTemplateSetupPage'; export { default as MarkSchemePage } from './MarkSchemePage'; +export { default as ExamMarkingPage } from './ExamMarkingPage'; +export { default as ExamResultsPage } from './ExamResultsPage'; +export { default as ResultsWidget } from './ResultsWidget'; diff --git a/src/pages/timetable/ClassDetailPage.tsx b/src/pages/timetable/ClassDetailPage.tsx index 399125b..4d763de 100644 --- a/src/pages/timetable/ClassDetailPage.tsx +++ b/src/pages/timetable/ClassDetailPage.tsx @@ -9,6 +9,7 @@ import { ArrowBack, PersonAdd, PersonRemove, CheckCircle, Cancel, School, } from '@mui/icons-material'; import { useAuth } from '../../contexts/AuthContext'; +import { ResultsWidget } from '../exam'; const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api'; @@ -254,6 +255,8 @@ const ClassDetailPage: React.FC = () => { )} + + {/* Tabs */} setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}> diff --git a/src/services/exam/examRepository.ts b/src/services/exam/examRepository.ts index b12bd1a..8f0b9e3 100644 --- a/src/services/exam/examRepository.ts +++ b/src/services/exam/examRepository.ts @@ -11,12 +11,17 @@ import { API_BASE } from '../../config/apiConfig'; import { logger } from '../../debugConfig'; import { supabase } from '../../supabaseClient'; import type { + BatchQueueResponse, + BatchResultsResponse, + CreateBatchPayload, CreateTemplatePayload, ExamBoundary, ExamQuestion, ExamResponseArea, ExamTemplate, ExamTemplateDetail, + MarkingBatch, + MarkUpsertPayload, Neo4jSyncResult, PatchQuestionPayload, SpecPoint, @@ -209,6 +214,48 @@ export const examRepository = { const res = await axios.post(`${EXAM_BASE}/templates/${templateId}/neo4j-sync`, {}, { headers }); return res.data; }, + + async createBatch(payload: CreateBatchPayload): Promise { + const headers = await authHeaders(); + const res = await axios.post(`${EXAM_BASE}/batches`, payload, { headers }); + return res.data; + }, + + async listBatches(params: { includeArchived?: boolean; templateId?: string } = {}): Promise { + const headers = await authHeaders(); + const res = await axios.get<{ batches: MarkingBatch[] }>(`${EXAM_BASE}/batches`, { + headers, + params: { + include_archived: params.includeArchived ?? false, + template_id: params.templateId, + }, + }); + return res.data.batches ?? []; + }, + + async getBatchQueue(batchId: string): Promise { + const headers = await authHeaders(); + const res = await axios.get(`${EXAM_BASE}/batches/${batchId}/queue`, { headers }); + return res.data; + }, + + async getBatchResults(batchId: string): Promise { + const headers = await authHeaders(); + const res = await axios.get(`${EXAM_BASE}/batches/${batchId}/results`, { headers }); + return res.data; + }, + + async getBatchCsv(batchId: string): Promise { + const headers = await authHeaders(); + const res = await axios.get(`${EXAM_BASE}/batches/${batchId}/csv`, { headers, responseType: 'text' }); + return res.data; + }, + + async upsertMark(markId: string, payload: MarkUpsertPayload): Promise { + const headers = await authHeaders(); + const res = await axios.put(`${EXAM_BASE}/marks/${markId}`, payload, { headers }); + return res.data; + }, }; export default examRepository; diff --git a/src/types/exam.types.ts b/src/types/exam.types.ts index b867d50..1b65186 100644 --- a/src/types/exam.types.ts +++ b/src/types/exam.types.ts @@ -201,3 +201,68 @@ export interface Neo4jSyncResult { status: string; projection?: Record; } + +export interface MarkingBatch { + id: string; + template_id: string; + class_id: string | null; + institute_id: string; + teacher_id: string; + title: string | null; + status: 'open' | 'closed' | 'archived' | string; + created_at: string; + updated_at?: string; + submission_count?: number; +} + +export interface StudentSubmission { + id: string; + batch_id: string; + student_id: string | null; + student_name: string | null; + status: 'absent' | 'unmatched' | 'matched' | 'marking' | 'complete' | string; + storage_path?: string | null; + mark_entry_count?: number; +} + +export interface BatchQueueResponse { + batch: MarkingBatch; + submissions: StudentSubmission[]; + progress: { + total: number; + absent: number; + complete: number; + in_progress: number; + }; +} + +export interface ExamResultRow { + submission_id: string; + student_id: string | null; + student_name: string | null; + status: string | null; + marks: Record; + total: number | null; +} + +export interface BatchResultsResponse { + batch: MarkingBatch; + questions: Array>; + results: ExamResultRow[]; +} + +export interface CreateBatchPayload { + template_id: string; + class_id?: string; + title?: string; +} + +export interface MarkUpsertPayload { + submission_id: string; + question_id: string; + awarded_marks: number; + mark_scheme_detail?: Record; + annotation_shape_ids?: unknown; + comment?: string; + confirmed?: boolean; +} From 15a519748dcf86e1a24f54751f2fa66cbbe890c1 Mon Sep 17 00:00:00 2001 From: CC Worker Date: Sun, 7 Jun 2026 10:12:16 +0000 Subject: [PATCH 12/14] fix(exam): keep region shapes overlaid on PDF in setup canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three z-order fixes in ExamTemplateSetupPage: 1. loadShapes: early-return before deletion when models is empty, so seed guide shapes aren't wiped for a fresh template that has no saved regions yet. 2. Incremental PDF loading (onPageReady): replace per-page sendToBack (unreliable when all shapes are moving — reorderToBack no-ops) with bringToFront on existing domain shapes after each page is added. 3. Final load sequence: call bringDomainShapesToFront after syncPdfPages + loadShapes to guarantee correct z-order regardless of how tldraw's fractional indexer placed newly created shapes. Also called from onMount for the same reason. Co-Authored-By: Claude Sonnet 4.6 --- src/pages/exam/setup/ExamTemplateSetupPage.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/pages/exam/setup/ExamTemplateSetupPage.tsx b/src/pages/exam/setup/ExamTemplateSetupPage.tsx index 7f020b5..5709f53 100644 --- a/src/pages/exam/setup/ExamTemplateSetupPage.tsx +++ b/src/pages/exam/setup/ExamTemplateSetupPage.tsx @@ -88,10 +88,15 @@ function modelFromTLShape(shape: TLShape): ExamCanvasShapeModel | null { } } +function bringDomainShapesToFront(editor: Editor) { + const ids = editor.getCurrentPageShapes().filter((s) => !!shapeTypeToKind(s.type)).map((s) => s.id) + if (ids.length) try { editor.bringToFront(ids as any) } catch { /* */ } +} + function loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) { + if (!models.length) return const existing = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)).map((s) => s.id) if (existing.length) editor.deleteShapes(existing) - if (!models.length) return editor.createShapes(models.map((m) => ({ id: createShapeId(m.id), type: SHAPE_TYPES[m.kind], @@ -117,8 +122,7 @@ function syncPdfPages(editor: Editor, pages: PdfPageImage[]) { props: { w: geometry.w, h: geometry.h, src: page.src, pageNumber: geometry.pageNumber }, } as any })) - const ids = geometries.map((geometry) => createShapeId(PDF_PAGE_IDS_PREFIX + geometry.pageNumber)) - try { editor.sendToBack(ids as any) } catch { /* tldraw 3 keeps creation order behind later region shapes */ } + // z-order is enforced by the caller via bringDomainShapesToFront } function seedGuide(editor: Editor) { @@ -170,7 +174,7 @@ const ExamTemplateSetupInner: React.FC = () => { const shapeId = createShapeId(PDF_PAGE_IDS_PREFIX + newPage.pageNumber) if (!ed.getCurrentPageShapes().find((s) => s.id === shapeId)) { ed.createShapes([{ id: shapeId, type: PDF_PAGE_SHAPE_TYPE, x: geometry.x, y: geometry.y, isLocked: true, props: { w: geometry.w, h: geometry.h, src: newPage.src, pageNumber: newPage.pageNumber } } as any]) - try { ed.sendToBack([shapeId as any]) } catch { /* */ } + bringDomainShapesToFront(ed) } } setPdfStatus('ready') @@ -188,6 +192,7 @@ const ExamTemplateSetupInner: React.FC = () => { if (editor) { syncPdfPages(editor, pages) loadShapes(editor, shapesFromTemplate(detail, geometries)) + bringDomainShapesToFront(editor) } setDirty(false) } catch (e) { @@ -256,6 +261,7 @@ const ExamTemplateSetupInner: React.FC = () => { editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' }) editor.store.listen(() => setDirty(true), { scope: 'document' }) if (template) loadShapes(editor, shapesFromTemplate(template, pageGeometriesRef.current)); else seedGuide(editor) + bringDomainShapesToFront(editor) }} /> From fe5dbe7fa8fd2992fda998c880fda2fcf9d6ae92 Mon Sep 17 00:00:00 2001 From: CC Worker Date: Sun, 7 Jun 2026 12:36:21 +0000 Subject: [PATCH 13/14] feat(exam): doc-view camera constraints and sidebar layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces infinite-canvas free-pan with a constrained vertical-scroll doc view: tldraw setCameraOptions with behavior='contain', fit-x-100, and top origin so the PDF never drifts side-to-side. Layout restructured from floating overlays to flex column (top bar + sidebar/canvas row). Tool panel is now a left sidebar, guide panel overlays canvas bottom-right. PAGE_START_X changed from 260 → 0 so pages are flush left; camera applyDocViewConstraints() called incrementally as pages stream in and with resetZoom() once all pages are loaded. Co-Authored-By: Claude Sonnet 4.6 --- .../exam/setup/ExamTemplateSetupPage.tsx | 120 +++++++++++------- 1 file changed, 77 insertions(+), 43 deletions(-) diff --git a/src/pages/exam/setup/ExamTemplateSetupPage.tsx b/src/pages/exam/setup/ExamTemplateSetupPage.tsx index 5709f53..48a5633 100644 --- a/src/pages/exam/setup/ExamTemplateSetupPage.tsx +++ b/src/pages/exam/setup/ExamTemplateSetupPage.tsx @@ -29,7 +29,7 @@ const TOOLS = [ { id: SHAPE_TYPES.furniture, label: 'Furniture', icon: canvasShapePalette.furniture.icon, tip: 'Mark page numbers, margins, blank space, or decoration to exclude from extraction.', color: 'inherit' as const }, ] -const PAGE_START_X = 260 +const PAGE_START_X = 0 const PDF_PAGE_IDS_PREFIX = 'pdf-page-' function pageGeometryFromImages(pages: PdfPageImage[]): CanvasPageGeometry[] { @@ -41,6 +41,22 @@ function pageGeometryFromImages(pages: PdfPageImage[]): CanvasPageGeometry[] { }) } +function applyDocViewConstraints(editor: Editor, pages: PdfPageImage[]) { + const maxW = pages.length ? Math.max(...pages.map((p) => p.width)) : PAGE_WIDTH + const totalH = pages.reduce((sum, p) => sum + p.height, 0) || PAGE_HEIGHT + editor.setCameraOptions({ + constraints: { + bounds: { x: -64, y: -64, w: maxW + 128, h: totalH + 128 }, + padding: { x: 64, y: 64 }, + origin: { x: 0.5, y: 0 }, + initialZoom: 'fit-x-100', + baseZoom: 'default', + behavior: 'contain', + }, + isLocked: false, + }) +} + function apiMessage(err: unknown): { message: string; conflict: boolean } { if (axios.isAxiosError(err)) { const detail = (err.response?.data as { detail?: string } | undefined)?.detail @@ -176,6 +192,7 @@ const ExamTemplateSetupInner: React.FC = () => { ed.createShapes([{ id: shapeId, type: PDF_PAGE_SHAPE_TYPE, x: geometry.x, y: geometry.y, isLocked: true, props: { w: geometry.w, h: geometry.h, src: newPage.src, pageNumber: newPage.pageNumber } } as any]) bringDomainShapesToFront(ed) } + applyDocViewConstraints(ed, partialPages) } setPdfStatus('ready') }) @@ -193,6 +210,8 @@ const ExamTemplateSetupInner: React.FC = () => { syncPdfPages(editor, pages) loadShapes(editor, shapesFromTemplate(detail, geometries)) bringDomainShapesToFront(editor) + applyDocViewConstraints(editor, pages) + editor.resetZoom() } setDirty(false) } catch (e) { @@ -248,25 +267,10 @@ const ExamTemplateSetupInner: React.FC = () => { )), [activeTool]) return ( - t.zIndex.drawer + 20, bgcolor: 'background.default' }}> - - { - editorRef.current = editor - editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' }) - editor.store.listen(() => setDirty(true), { scope: 'document' }) - if (template) loadShapes(editor, shapesFromTemplate(template, pageGeometriesRef.current)); else seedGuide(editor) - bringDomainShapesToFront(editor) - }} - /> - + t.zIndex.drawer + 20, bgcolor: 'background.default', display: 'flex', flexDirection: 'column' }}> - + {/* Top bar */} + @@ -277,32 +281,62 @@ const ExamTemplateSetupInner: React.FC = () => { - - {toolButtons} - + {/* Body row */} + - - Setup guide - - 1) Boundary start/end lines define each main question. 2) Draw amber Part boxes for markable sub-questions. 3) Draw coloured Response, Context, Q Number, Mark Area, Reference, and Furniture regions; Save derives parent links by containment. - - - {(['boundary', 'part', 'response', 'context', 'question_number', 'mark_area', 'reference', 'furniture'] as const).map((kind) => { - const p = canvasShapePalette[kind] - return - })} - - - Multi-page boundary pairing - Draw “Q start” on page N, then “Q end” on a later page; save pairs boundaries by reading order into one question span. - Open design choices resolved for v1: labels use “Q start/end”; persistent Attached pills confirm containment; rectangles stay simple for dense multi-column papers; Back button remains explicit. - - PDF backdrop: {pdfStatus === 'ready' ? 'loaded and locked behind regions' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF for this template' : pdfError ?? 'failed to load'} - - + {/* Left tool sidebar */} + + {toolButtons} + - {loading && } - {conflict && setConflict(null)}>{conflict}} + {/* Canvas area */} + + + { + editorRef.current = editor + editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' }) + editor.store.listen(() => setDirty(true), { scope: 'document' }) + applyDocViewConstraints(editor, []) + editor.resetZoom() + if (template) loadShapes(editor, shapesFromTemplate(template, pageGeometriesRef.current)); else seedGuide(editor) + bringDomainShapesToFront(editor) + }} + /> + + + {/* Guide panel */} + + Setup guide + + 1) Boundary start/end lines define each main question. 2) Draw amber Part boxes for markable sub-questions. 3) Draw coloured Response, Context, Q Number, Mark Area, Reference, and Furniture regions; Save derives parent links by containment. + + + {(['boundary', 'part', 'response', 'context', 'question_number', 'mark_area', 'reference', 'furniture'] as const).map((kind) => { + const p = canvasShapePalette[kind] + return + })} + + + Multi-page boundary pairing + Draw "Q start" on page N, then "Q end" on a later page; save pairs boundaries by reading order into one question span. + Open design choices resolved for v1: labels use "Q start/end"; persistent Attached pills confirm containment; rectangles stay simple for dense multi-column papers; Back button remains explicit. + + PDF backdrop: {pdfStatus === 'ready' ? 'loaded and locked behind regions' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF for this template' : pdfError ?? 'failed to load'} + + + + {/* Conflict alert */} + {conflict && setConflict(null)}>{conflict}} + + + + {loading && } setError(null)}> setError(null)}>{error} ) From 66f35b8ae4a074fdbfbdbecaa53722203b623c40 Mon Sep 17 00:00:00 2001 From: CC Worker Date: Sun, 7 Jun 2026 13:37:47 +0000 Subject: [PATCH 14/14] fix(exam): compact top bar, collapsible guide panel Top bar reduced to single-line height: Back becomes an icon button, caption line removed (detail lives in the guide), Save button size=small. Guide panel defaults collapsed and toggles via a ? icon button at bottom-right so it doesn't occupy permanent canvas real estate. Co-Authored-By: Claude Sonnet 4.6 --- .../exam/setup/ExamTemplateSetupPage.tsx | 67 +++++++++++-------- 1 file changed, 38 insertions(+), 29 deletions(-) diff --git a/src/pages/exam/setup/ExamTemplateSetupPage.tsx b/src/pages/exam/setup/ExamTemplateSetupPage.tsx index 48a5633..ba08234 100644 --- a/src/pages/exam/setup/ExamTemplateSetupPage.tsx +++ b/src/pages/exam/setup/ExamTemplateSetupPage.tsx @@ -1,8 +1,9 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' -import { Alert, Box, Button, Chip, CircularProgress, Divider, Paper, Snackbar, Stack, Tooltip, Typography, useTheme } from '@mui/material' +import { Alert, Box, Button, Chip, CircularProgress, Collapse, Divider, IconButton, Paper, Snackbar, Stack, Tooltip, Typography, useTheme } from '@mui/material' import ArrowBackIcon from '@mui/icons-material/ArrowBack' +import HelpOutlineIcon from '@mui/icons-material/HelpOutline' import SaveIcon from '@mui/icons-material/Save' import MouseIcon from '@mui/icons-material/Mouse' import '@tldraw/tldraw/tldraw.css' @@ -168,6 +169,7 @@ const ExamTemplateSetupInner: React.FC = () => { const [activeTool, setActiveTool] = useState('select') const [pdfStatus, setPdfStatus] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading') const [pdfError, setPdfError] = useState(null) + const [guideOpen, setGuideOpen] = useState(false) const load = useCallback(async () => { if (!templateId) return @@ -269,16 +271,15 @@ const ExamTemplateSetupInner: React.FC = () => { return ( t.zIndex.drawer + 20, bgcolor: 'background.default', display: 'flex', flexDirection: 'column' }}> - {/* Top bar */} - - + {/* Top bar — single compact line */} + + + navigate('/exam-marker')} size="small"> + - - {template?.title ?? 'Template setup'} - Exam Marker › Setup · coloured tools map to persisted regions; boundary start/end pairs can span pages. - + {template?.title ?? 'Template setup'} - + {/* Body row */} @@ -310,26 +311,34 @@ const ExamTemplateSetupInner: React.FC = () => { /> - {/* Guide panel */} - - Setup guide - - 1) Boundary start/end lines define each main question. 2) Draw amber Part boxes for markable sub-questions. 3) Draw coloured Response, Context, Q Number, Mark Area, Reference, and Furniture regions; Save derives parent links by containment. - - - {(['boundary', 'part', 'response', 'context', 'question_number', 'mark_area', 'reference', 'furniture'] as const).map((kind) => { - const p = canvasShapePalette[kind] - return - })} - - - Multi-page boundary pairing - Draw "Q start" on page N, then "Q end" on a later page; save pairs boundaries by reading order into one question span. - Open design choices resolved for v1: labels use "Q start/end"; persistent Attached pills confirm containment; rectangles stay simple for dense multi-column papers; Back button remains explicit. - - PDF backdrop: {pdfStatus === 'ready' ? 'loaded and locked behind regions' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF for this template' : pdfError ?? 'failed to load'} - - + {/* Guide toggle */} + + setGuideOpen((v) => !v)} size="small" sx={{ position: 'absolute', right: 16, bottom: 16, zIndex: 1001, bgcolor: 'background.paper', boxShadow: 2, '&:hover': { bgcolor: 'background.paper' } }}> + + + + + {/* Guide panel — collapsible */} + + + Setup guide + + 1) Boundary start/end lines define each main question. 2) Draw amber Part boxes for markable sub-questions. 3) Draw coloured Response, Context, Q Number, Mark Area, Reference, and Furniture regions; Save derives parent links by containment. + + + {(['boundary', 'part', 'response', 'context', 'question_number', 'mark_area', 'reference', 'furniture'] as const).map((kind) => { + const p = canvasShapePalette[kind] + return + })} + + + Multi-page boundary pairing + Draw "Q start" on page N, then "Q end" on a later page; save pairs boundaries by reading order into one question span. + + PDF: {pdfStatus === 'ready' ? 'loaded' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF' : pdfError ?? 'failed'} + + + {/* Conflict alert */} {conflict && setConflict(null)}>{conflict}}