import { View, Exam, Submission } from './types';
import { saveExams, loadExams, saveSubmissions, loadSubmissions, generateId, exportToJSON, importFromJSON } from './store';
import { autoGradeSubmission, formatTime } from './utils';
// App state
let currentView: View = 'dashboard';
let editingExamId: string | null = null;
let takingExamId: string | null = null;
let gradingSubmissionId: string | null = null;
let viewingResultsExamId: string | null = null;
// DOM
const app = document.getElementById('app')!;
function render() {
app.innerHTML = '';
switch (currentView) {
case 'dashboard': renderDashboard(); break;
case 'editor': renderEditor(); break;
case 'take': renderTake(); break;
case 'submissions': renderSubmissions(); break;
case 'grade': renderGrade(); break;
case 'results': renderResults(); break;
case 'export': renderExport(); break;
}
}
function navigate(view: View, params?: { examId?: string; submissionId?: string }) {
currentView = view;
editingExamId = params?.examId ?? null;
takingExamId = params?.examId ?? null;
gradingSubmissionId = params?.submissionId ?? null;
viewingResultsExamId = params?.examId ?? null;
render();
}
function renderNav(): string {
return `
`;
}
// Make navigate available globally for inline handlers
(window as any).navigate = navigate;
function renderDashboard() {
const exams = loadExams();
const submissions = loadSubmissions();
app.innerHTML = `
${renderNav()}
Digital Exams
Create, conduct, and grade exams — all in your browser. No server. No data leaves your device.
${submissions.length}
Submissions
${submissions.filter(s => s.graded).length}
Graded
Your Exams
${exams.length === 0 ? `
No exams yet. Create your first exam to get started.
` : `
${exams.map(exam => `
${escapeHtml(exam.subject)}
${escapeHtml(exam.description)}
⏱ ${exam.timeLimit > 0 ? exam.timeLimit + ' min' : 'No limit'}
🔀 ${exam.shuffleQuestions ? 'Shuffled' : 'Fixed order'}
`).join('')}
`}
Recent Submissions
${submissions.length === 0 ? `
No submissions yet. Take an exam to see results here.
` : `
${submissions.slice(0, 10).map(sub => {
const exam = exams.find(e => e.id === sub.examId);
return `
${escapeHtml(sub.studentName)}
${escapeHtml(exam?.title ?? 'Unknown exam')}
${sub.percentage}%
${sub.graded ? 'Graded' : 'Pending'}
`;
}).join('')}
`}
`;
}
function escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
(window as any).createNewExam = () => {
editingExamId = null;
navigate('editor');
};
(window as any).editExam = (id: string) => navigate('editor', { examId: id });
(window as any).takeExam = (id: string) => navigate('take', { examId: id });
(window as any).viewResults = (id: string) => navigate('results', { examId: id });
(window as any).gradeSubmission = (id: string) => navigate('grade', { submissionId: id });
(window as any).deleteExam = (id: string) => {
if (!confirm('Delete this exam and all its submissions?')) return;
const exams = loadExams().filter(e => e.id !== id);
const submissions = loadSubmissions().filter(s => s.examId !== id);
saveExams(exams);
saveSubmissions(submissions);
render();
};
function renderEditor() {
const exams = loadExams();
const exam = editingExamId ? exams.find(e => e.id === editingExamId) : null;
const questions = exam?.questions ?? [];
app.innerHTML = `
${renderNav()}
Questions (${questions.length})
${questions.map((q, i) => renderQuestionEditor(q, i)).join('')}
`;
}
function renderQuestionEditor(q: any, index: number): string {
const optionsHtml = q.options?.map((opt: string, i: number) => `
`).join('') ?? '';
const optionsEditor = q.type === 'multiple_choice' ? `
${optionsHtml}
` : '';
const correctAnswerInput = q.type === 'fill_blank' ? `
` : q.type === 'true_false' ? `
` : '';
return `
${optionsEditor}
${correctAnswerInput}
`;
}
(window as any).addQuestion = (type: string) => {
const container = document.getElementById('questions-container')!;
const qid = generateId();
const newQ = {
id: qid,
type,
text: '',
points: type === 'essay' ? 10 : (type === 'short_answer' ? 5 : 1),
options: type === 'multiple_choice' ? ['', ''] : undefined,
correctAnswer: type === 'true_false' ? 'true' : undefined,
};
const div = document.createElement('div');
div.innerHTML = renderQuestionEditor(newQ, container.children.length);
container.appendChild(div.firstElementChild!);
};
(window as any).addOption = (qid: string) => {
const list = document.getElementById(`options-${qid}`)!;
const idx = list.querySelectorAll('.option-row').length;
const row = document.createElement('div');
row.className = 'option-row';
row.innerHTML = `
`;
list.insertBefore(row, list.lastElementChild);
};
(window as any).removeOption = (qid: string, idx: number) => {
const list = document.getElementById(`options-${qid}`)!;
const rows = list.querySelectorAll('.option-row');
if (rows[idx]) rows[idx].remove();
};
(window as any).removeQuestion = (qid: string) => {
const el = document.querySelector(`[data-qid="${qid}"]`);
el?.remove();
// Renumber
document.querySelectorAll('.question-editor').forEach((el, i) => {
el.querySelector('.question-number')!.textContent = String(i + 1);
});
};
(window as any).saveExam = () => {
const title = (document.getElementById('exam-title') as HTMLInputElement).value.trim();
if (!title) { alert('Title is required'); return; }
const exam: Exam = {
id: editingExamId ?? generateId(),
title,
subject: (document.getElementById('exam-subject') as HTMLInputElement).value.trim(),
description: (document.getElementById('exam-desc') as HTMLTextAreaElement).value.trim(),
timeLimit: parseInt((document.getElementById('exam-time') as HTMLInputElement).value) || 0,
shuffleQuestions: (document.getElementById('exam-shuffle') as HTMLInputElement).checked,
allowRetake: (document.getElementById('exam-retake') as HTMLInputElement).checked,
showResults: (document.getElementById('exam-showresults') as HTMLInputElement).checked,
questions: [],
createdAt: editingExamId ? loadExams().find(e => e.id === editingExamId)!.createdAt : new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// Collect questions from DOM
document.querySelectorAll('.question-editor').forEach(el => {
const qid = el.getAttribute('data-qid')!;
const type = el.querySelector('.question-type-badge')!.textContent!.replace(' ', '_') as any;
const text = (el.querySelector(`#text-${qid}`) as HTMLTextAreaElement).value.trim();
const points = parseInt((el.querySelector(`#points-${qid}`) as HTMLInputElement).value) || 0;
const q: any = { id: qid, type, text, points };
if (type === 'multiple_choice') {
const options: string[] = [];
let correctAnswer = '';
el.querySelectorAll('.option-row').forEach((row, i) => {
const optText = (row.querySelector('.option-text') as HTMLInputElement)?.value.trim() ?? '';
if (optText) options.push(optText);
const radio = row.querySelector('input[type="radio"]') as HTMLInputElement;
if (radio?.checked) correctAnswer = String(i);
});
q.options = options;
q.correctAnswer = correctAnswer;
} else if (type === 'true_false') {
q.correctAnswer = (el.querySelector(`#correct-${qid}`) as HTMLSelectElement)?.value;
} else if (type === 'fill_blank') {
const raw = (el.querySelector(`#correct-${qid}`) as HTMLInputElement)?.value.trim() ?? '';
q.correctAnswer = raw ? raw.split(',').map((s: string) => s.trim()).filter(Boolean) : [];
q.caseSensitive = (el.querySelector(`#case-${qid}`) as HTMLInputElement)?.checked;
}
exam.questions.push(q);
});
const exams = loadExams().filter(e => e.id !== exam.id);
exams.push(exam);
saveExams(exams);
navigate('dashboard');
};
function renderTake() {
const exams = loadExams();
const exam = exams.find(e => e.id === takingExamId);
if (!exam) {
app.innerHTML = ``;
return;
}
const questions = exam.shuffleQuestions
? [...exam.questions].sort(() => Math.random() - 0.5)
: exam.questions;
app.innerHTML = `
${renderNav()}
${questions.map((q, i) => renderQuestionTake(q, i)).join('')}
`;
// Start timer
if (exam.timeLimit > 0) {
let remaining = exam.timeLimit * 60;
const timerEl = document.getElementById('timer')!;
const interval = setInterval(() => {
remaining--;
timerEl.textContent = `⏱ ${formatTime(remaining)}`;
if (remaining <= 0) {
clearInterval(interval);
alert('Time is up! Submitting your exam.');
(window as any).submitExam();
}
}, 1000);
(window as any)._examTimer = interval;
}
}
function renderQuestionTake(q: any, index: number): string {
const inputName = `q-${q.id}`;
switch (q.type) {
case 'multiple_choice':
return `
`;
case 'true_false':
return `
`;
case 'fill_blank':
return `
`;
case 'short_answer':
return `
${index + 1}. ${escapeHtml(q.text)} (${q.points} pts)
`;
case 'essay':
return `
${index + 1}. ${escapeHtml(q.text)} (${q.points} pts)
`;
default:
return '';
}
}
(window as any).submitExam = () => {
const studentName = (document.getElementById('student-name') as HTMLInputElement)?.value.trim();
if (!studentName) { alert('Please enter your name'); return; }
const exams = loadExams();
const exam = exams.find(e => e.id === takingExamId);
if (!exam) return;
const answers: any[] = [];
exam.questions.forEach(q => {
const inputName = `q-${q.id}`;
if (q.type === 'multiple_choice' || q.type === 'true_false') {
const selected = document.querySelector(`input[name="${inputName}"]:checked`) as HTMLInputElement;
if (selected) answers.push({ questionId: q.id, value: selected.value });
} else {
const el = document.querySelector(`[name="${inputName}"]`) as HTMLInputElement | HTMLTextAreaElement;
if (el) answers.push({ questionId: q.id, value: el.value });
}
});
if ((window as any)._examTimer) clearInterval((window as any)._examTimer);
const submission: Submission = {
id: generateId(),
examId: exam.id,
studentName,
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
answers,
scores: {},
totalScore: 0,
maxScore: 0,
percentage: 0,
graded: false,
};
const questionsMap = new Map(exam.questions.map(q => [q.id, q]));
const graded = autoGradeSubmission(submission, questionsMap);
const submissions = loadSubmissions();
submissions.push(graded);
saveSubmissions(submissions);
if (exam.showResults) {
alert(`Submitted! You scored ${graded.percentage}% (${graded.totalScore}/${graded.maxScore})`);
} else {
alert('Submitted! Your teacher will review your answers.');
}
navigate('dashboard');
};
function renderGrade() {
const submissions = loadSubmissions();
const submission = submissions.find(s => s.id === gradingSubmissionId);
const exams = loadExams();
const exam = submission ? exams.find(e => e.id === submission.examId) : null;
if (!submission || !exam) {
app.innerHTML = `Submission not found.
`;
return;
}
app.innerHTML = `
${renderNav()}
Current: ${submission.totalScore} / ${submission.maxScore} = ${submission.percentage}%
${exam.questions.map(q => {
const answer = submission.answers.find(a => a.questionId === q.id);
const score = submission.scores[q.id] ?? 0;
const needsManual = q.type === 'short_answer' || q.type === 'essay';
const autoGraded = !needsManual && submission.scores[q.id] !== undefined;
return `
${escapeHtml(q.text)} (${q.points} pts)
Answer: ${escapeHtml(String(answer?.value ?? 'No answer'))}
${q.correctAnswer ? `
Expected: ${escapeHtml(Array.isArray(q.correctAnswer) ? q.correctAnswer.join(' or ') : q.correctAnswer)}
` : ''}
${autoGraded ? 'Auto-graded' : (needsManual ? 'Manual' : '')}
`;
}).join('')}
`;
}
(window as any).updateScore = (qid: string, maxPoints: number) => {
const el = document.getElementById(`score-${qid}`) as HTMLInputElement;
let val = parseFloat(el.value);
if (val < 0) val = 0;
if (val > maxPoints) val = maxPoints;
el.value = String(val);
};
(window as any).saveGrades = () => {
const submissions = loadSubmissions();
const submission = submissions.find(s => s.id === gradingSubmissionId);
if (!submission) return;
const exams = loadExams();
const exam = exams.find(e => e.id === submission.examId);
if (!exam) return;
let totalScore = 0;
const scores: Record = {};
exam.questions.forEach(q => {
const el = document.getElementById(`score-${q.id}`) as HTMLInputElement;
const score = parseFloat(el.value) || 0;
scores[q.id] = score;
totalScore += score;
});
const maxScore = exam.questions.reduce((a, q) => a + q.points, 0);
const percentage = maxScore > 0 ? Math.round((totalScore / maxScore) * 100) : 0;
const idx = submissions.findIndex(s => s.id === gradingSubmissionId);
submissions[idx] = {
...submission,
scores,
totalScore,
maxScore,
percentage,
graded: true,
gradedAt: new Date().toISOString(),
};
saveSubmissions(submissions);
alert('Grades saved!');
navigate('dashboard');
};
function renderResults() {
const exams = loadExams();
const exam = exams.find(e => e.id === viewingResultsExamId);
const submissions = loadSubmissions().filter(s => s.examId === viewingResultsExamId);
if (!exam) {
app.innerHTML = ``;
return;
}
const avgScore = submissions.length > 0
? Math.round(submissions.reduce((a, s) => a + s.percentage, 0) / submissions.length)
: 0;
const gradedCount = submissions.filter(s => s.graded).length;
app.innerHTML = `
${renderNav()}
${submissions.length}
Submissions
${submissions.length > 0 ? Math.max(...submissions.map(s => s.percentage)) : 0}%
Highest
Submissions
${submissions.length === 0 ? '
No submissions yet.
' : `
${submissions.map(sub => `
${escapeHtml(sub.studentName)}
${sub.percentage}%
${sub.graded ? 'Graded' : 'Pending'}
${new Date(sub.completedAt).toLocaleDateString()}
`).join('')}
`}
Question Breakdown
${exam.questions.map(q => {
const questionSubmissions = submissions.map(sub => {
const answer = sub.answers.find(a => a.questionId === q.id);
return { score: sub.scores[q.id] ?? 0, answer: answer?.value ?? '' };
});
const avgQScore = questionSubmissions.length > 0
? Math.round((questionSubmissions.reduce((a, s) => a + s.score, 0) / (questionSubmissions.length * q.points)) * 100)
: 0;
return `
${escapeHtml(q.text.substring(0, 60))}${q.text.length > 60 ? '...' : ''}
${avgQScore}% avg
`;
}).join('')}
`;
}
function renderSubmissions() {
navigate('dashboard');
}
function renderExport() {
app.innerHTML = `
${renderNav()}
Export Exams
Download all your exams as a JSON file.
Export Submissions
Download all submissions as a JSON file.
`;
}
(window as any).exportExams = () => {
exportToJSON(loadExams(), `intelligrade-exams-${new Date().toISOString().slice(0, 10)}.json`);
};
(window as any).exportSubmissions = () => {
exportToJSON(loadSubmissions(), `intelligrade-submissions-${new Date().toISOString().slice(0, 10)}.json`);
};
(window as any).importExams = async () => {
const input = document.getElementById('import-file') as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
try {
const data = await importFromJSON(file) as Exam[];
if (!Array.isArray(data)) { alert('Invalid file format'); return; }
const existing = loadExams();
const merged = [...existing];
for (const exam of data) {
if (!existing.find(e => e.id === exam.id)) {
exam.id = generateId(); // Reassign IDs to avoid conflicts
exam.createdAt = new Date().toISOString();
merged.push(exam);
}
}
saveExams(merged);
alert(`Imported ${data.length} exams`);
input.value = '';
navigate('dashboard');
} catch (e) {
alert('Failed to import: ' + (e as Error).message);
}
};
// Initial render
render();