whaleprep/static/exam.js
dev 14542f169a Make the exam screen work on a phone
The screen had no mobile state at all: the clock scrolled away mid-paper, the
only navigation was save-and-next so a candidate could not go back, the palette
sat below the question where it pushed submit off-screen, and the nav row lost
three of its four links under 700px.

Sticky header carries the clock and a progress bar; previous/next are real
buttons; the palette is a drawer below 1024px opened by a floating button that
sits above the tab bar. Primary destinations move to a bottom tab bar on
phones, data tables scroll inside their own card, and every control meets the
44px touch minimum.

Verified with a headless browser across five viewports rather than by reading
the stylesheet: scripts/audit_mobile.py fails on horizontal overflow, tap
targets under 44px, and content hidden behind a fixed bar; scripts/shots.py
captures each page at both widths. Both caught bugs I would otherwise have
shipped, including a palette toggle that lived inside the drawer it opened and
so could never be pressed on a phone.

fmt_dur drops a leading zero hour, so a 24 minute paper reads 24m 00s.
2026-10-11 21:54:17 +05:30

212 lines
No EOL
8.1 KiB
JavaScript

// 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),
};
}
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);
})();