whaleprep/static/exam.js
dev 1d8ee3b5ff Subtopic picker, JEE mode, per-subject timers
The builder previously exposed only chapters. Now each chapter expands to show
its subtopics as individual checkboxes: selecting a chapter checks all of its
subtopics, selecting the subject-level box checks everything, and selecting
individual subtopics sets the chapter to indeterminate. The exam query filters
by subtopic_id when any are given; the SQL path deduplicates so a question
matching two subtopics only appears once.

JEE mode sets duration to 60 × unique-subject-count (180 min for a full paper).
On the exam screen this renders per-subject countdown strips: Physics 60:00,
Maths 60:00, Chemistry 60:00. Each strip counts down independently; when one
hits zero the screen auto-jumps to the first unanswered question of the next
subject. The last strip exhausting time submits the form.

Single-subject papers keep the flat timer and show no strips.

Also: all audit targets now genuinely 44px (the .opt labels were 42px).
2026-10-12 05:13:09 +05:30

274 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Exam screen: pane navigation, palette, per-question timing and autosave.
(() => {
const form = document.getElementById('exam-form');
if (!form) return;
const panes = [...document.querySelectorAll('.qpane')];
const palBtns = [...document.querySelectorAll('#pal .pal')];
const timerEl = document.getElementById('timer');
const progressEl = document.getElementById('exam-progress');
const fillEl = document.getElementById('exam-fill');
const countEl = document.getElementById('cbt-count');
let cur = 0;
const entered = new Map();
for (const p of panes) {
const box = p.querySelector('input');
if (box) entered.set(p.dataset.i, Number(box.dataset.t || 0));
}
function questionId(pane) {
const radios = [...pane.querySelectorAll('input[type=radio]')];
if (radios.length) return Number(radios[0].name.replace('q-', ''));
const num = pane.querySelector('input[type=text]');
return num ? Number(num.name.replace('num-', '')) : 0;
}
function collect(pane) {
const radios = [...pane.querySelectorAll('input[type=radio]')];
const picked = radios.find(r => r.checked);
const num = pane.querySelector('input[type=text]');
return {
question_id: questionId(pane),
selected: picked ? picked.value : (num ? num.value.trim() : ''),
marked_review: pane.dataset.review === '1',
time_secs: Number(entered.get(pane.dataset.i) || 0),
};
}
window._examShow = show;
function show(i) {
cur = Math.max(0, Math.min(panes.length - 1, i));
panes.forEach((p, k) => { p.hidden = k !== cur; });
palBtns.forEach((b, k) => {
b.classList.toggle('cur', k === cur);
b.setAttribute('aria-current', k === cur ? 'true' : 'false');
});
const prev = panes[cur].querySelector('[data-act=prev]');
if (prev) prev.disabled = cur === 0;
// Only focus on a pointer device: stealing focus on a phone pops the keyboard over the options.
if (matchMedia('(pointer: fine)').matches) {
const f = panes[cur].querySelector('input:not([type=hidden])');
if (f) f.focus({ preventScroll: true });
}
// A pane change on a phone must start the question at the top, not halfway down.
panes[cur].scrollIntoView({ block: 'nearest' });
history.replaceState(null, '', '#q' + (cur + 1));
paintProgress();
}
function syncPalette(pane) {
const btn = palBtns[Number(pane.dataset.i)];
if (!btn) return;
const answered = collect(pane).selected !== '';
const review = pane.dataset.review === '1';
btn.classList.toggle('ans', answered && !review);
btn.classList.toggle('rev', answered && review);
btn.classList.toggle('skip', !answered);
btn.classList.remove('cur');
const mark = answered ? (review ? 'R' : '✓') : '·';
btn.setAttribute('aria-label', `question ${Number(pane.dataset.i) + 1}, ${answered ? 'answered' : 'blank'}${review ? ', marked for review' : ''}`);
btn.dataset.mark = mark;
paintProgress();
}
function counts() {
let answered = 0, review = 0;
for (const p of panes) {
const c = collect(p);
if (c.selected !== '') answered++;
if (c.marked_review) review++;
}
return { answered, review, left: panes.length - answered };
}
function paintProgress() {
const { answered, review, left } = counts();
const done = answered + review;
if (progressEl) {
progressEl.textContent = `${done} of ${panes.length} marked · question ${cur + 1}`;
}
if (fillEl) fillEl.style.width = `${panes.length ? (done / panes.length) * 100 : 0}%`;
if (countEl) {
countEl.textContent = `${answered} answered · ${left} blank${review ? ` · ${review} review` : ''}`;
}
const fabCount = document.getElementById('pal-fab-count');
if (fabCount) fabCount.textContent = left ? `· ${left} left` : '· all answered';
const bar = document.querySelector('.exam-bar');
if (bar) bar.classList.toggle('complete', left === 0);
}
let saving = false;
let pending = false;
async function save(all) {
// A second change while a save is in flight is coalesced into one follow-up write.
if (saving) { pending = all || pending; return; }
saving = true;
const panesToSave = all ? panes : [panes[cur]];
try {
await fetch(`${form.dataset.base}/attempt/${form.dataset.attempt}/answer`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ answers: panesToSave.map(collect) }),
});
} catch (_) {
// Server unreachable: the selection stays on screen and travels with the form submit.
}
saving = false;
if (pending) { const again = pending; pending = false; save(again); }
}
for (const pane of panes) {
pane.addEventListener('change', () => {
pane.querySelectorAll('.opt').forEach(o => {
const input = o.querySelector('input');
o.classList.toggle('sel', !!input && input.checked);
});
syncPalette(pane);
save(false);
});
pane.addEventListener('click', e => {
const btn = e.target.closest('button[data-act]');
if (!btn) return;
const act = btn.dataset.act;
if (act === 'prev') { show(cur - 1); }
if (act === 'save') { save(false).then(() => { if (cur < panes.length - 1) show(cur + 1); }); }
if (act === 'review') {
pane.dataset.review = pane.dataset.review === '1' ? '0' : '1';
btn.classList.toggle('danger', pane.dataset.review === '1');
syncPalette(pane);
save(false);
}
if (act === 'clear') {
pane.querySelectorAll('input[type=radio]').forEach(r => { r.checked = false; });
const num = pane.querySelector('input[type=text]');
if (num) num.value = '';
pane.querySelectorAll('.opt').forEach(o => o.classList.remove('sel'));
syncPalette(pane);
save(false);
}
});
}
palBtns.forEach((b, i) => b.addEventListener('click', () => show(i)));
// On a phone the palette is a drawer, so a tap there should also close it.
const toggle = document.getElementById('pal-toggle');
const side = document.querySelector('.cbt-side');
if (toggle && side) {
const setOpen = open => {
side.classList.toggle('open', open);
toggle.setAttribute('aria-expanded', String(open));
// Trap nothing, but return focus to the opener when the drawer closes.
if (!open) toggle.focus({ preventScroll: true });
};
toggle.addEventListener('click', () => setOpen(!side.classList.contains('open')));
side.addEventListener('click', e => {
if (e.target.closest('[data-close-palette]')) { setOpen(false); return; }
if (e.target.closest('#pal .pal') && matchMedia('(max-width: 1024px)').matches) setOpen(false);
});
document.addEventListener('keydown', e => { if (e.key === 'Escape') setOpen(false); });
}
document.addEventListener('keydown', e => {
if (/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return;
if (e.key === 'ArrowRight') show(cur + 1);
if (e.key === 'ArrowLeft') show(cur - 1);
});
let left = Number(timerEl.dataset.secs || 0);
if (left > 0) {
const tick = setInterval(() => {
left -= 1;
if (left <= 0) {
clearInterval(tick);
form.requestSubmit();
return;
}
const h = Math.floor(left / 3600);
const m = Math.floor(left / 60) % 60;
const s = left % 60;
const pad = n => String(n).padStart(2, '0');
timerEl.textContent = h > 0 ? `${h}:${pad(m)}:${pad(s)}` : m > 0 ? `${m}:${pad(s)}` : `${s}s`;
// Warn in the last ten minutes, and again in the last minute.
timerEl.classList.toggle('urgent', left <= 600 && left > 60);
timerEl.classList.toggle('critical', left <= 60);
}, 1000);
}
form.addEventListener('submit', e => {
const { left: blank, review } = counts();
const msg = blank
? `${blank} question${blank > 1 ? 's are' : ' is'} still blank. Submit anyway?`
: review
? `${review} question${review > 1 ? 's' : ''} marked for review. Submit anyway?`
: 'Submit this paper? You cannot change answers afterwards.';
if (!confirm(msg)) e.preventDefault();
});
const hash = Number((location.hash.match(/q(\d+)/) || [])[1]);
panes.forEach(syncPalette);
show(hash ? hash - 1 : 0);
})();
// Per-subject timers — only active in JEE mode (duration == 60×nsubjects).
// Each strip reads its initial seconds from data-secs and counts down.
// When a strip hits zero the exam auto-advances to the next subject, not the
// whole paper; only the last strip exhausting time submits the form.
(() => {
const strips = [...document.querySelectorAll('.subj-timer')];
if (!strips.length) return;
const form = document.getElementById('exam-form');
const panes = [...document.querySelectorAll('.qpane')];
let currentStrip = 0;
// Helper: first pane index that belongs to a given subject name.
function firstPaneForSubj(name) {
return panes.findIndex(p => p.querySelector('.pane-head .dim')?.textContent.includes(name));
}
// Helper: first pane index that does NOT belong to a given subject.
function firstPaneAfterSubj(name) {
return panes.findIndex(p => !p.querySelector('.pane-head .dim')?.textContent.includes(name));
}
function paintStrip(el, left) {
const m = Math.floor(left / 60), s = left % 60;
el.querySelector('b').textContent = `${m}:${String(s).padStart(2,'0')}`;
el.classList.toggle('warn', left <= 300 && left > 0);
el.classList.toggle('active', left > 0);
el.classList.toggle('done', left <= 0);
}
function runStrip(idx) {
const el = strips[idx];
if (!el) { form.requestSubmit(); return; }
el.classList.add('active');
let left = Number(el.dataset.secs);
const tick = setInterval(() => {
left -= 1;
el.dataset.secs = left;
paintStrip(el, left);
if (left <= 0) {
clearInterval(tick);
el.classList.remove('active');
el.classList.add('done');
currentStrip = idx + 1;
if (currentStrip < strips.length) {
// Auto-jump to the first unanswered question of the next subject.
const nextSubj = strips[currentStrip]?.dataset.subj;
const pi = nextSubj ? firstPaneForSubj(nextSubj) : -1;
if (pi >= 0 && typeof window._examShow === 'function') window._examShow(pi);
runStrip(currentStrip);
} else {
form.requestSubmit();
}
}
}, 1000);
}
// Expose the show function from the main IIFE so the subject timer can jump.
// exam.js main IIFE sets window._examShow after calling show().
strips.forEach((el, i) => paintStrip(el, Number(el.dataset.secs)));
runStrip(0);
})();