Files

68 lines
4.3 KiB
JavaScript

const exam = document.querySelector('#exam-form');
if (exam) {
const questions = [...document.querySelectorAll('[data-question]')];
const status = document.querySelector('#exam-status');
const timer = document.querySelector('[data-remaining]');
let end = performance.now() + Number(timer.dataset.remaining) * 1000;
let queue = Promise.resolve(), closed = false, submitting = false, dirty = false, revision = 0, pending = 0;
const say = (message, warning = false) => { status.textContent = message; status.classList.toggle('sync-warning', warning); };
const updateProgress = () => {
const count = questions.filter(q => [...q.querySelectorAll('input:not([type=hidden])')].some(i => i.type === 'radio' ? i.checked : i.value.trim())).length;
document.querySelector('#answered-count').textContent = count;
document.querySelector('#answer-progress').value = count;
};
const freeze = () => { closed = true; exam.querySelectorAll('input:not([type=hidden]),button').forEach(i => { i.disabled = true; }); };
const send = async (action, eventType = '') => {
if (closed) return;
const snapshotRevision = revision;
const data = new FormData(exam); data.set('action', action);
if (eventType) data.set('event', eventType);
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
let response;
try { response = await fetch(exam.action, {method:'POST', body:data, credentials:'same-origin', cache:'no-store', signal:controller.signal}); } finally { clearTimeout(timeout); }
const result = await response.json();
if (result.stale) { freeze(); say(result.error, true); return; }
if (result.closed) { freeze(); window.location.assign(result.redirect || 'home.php'); return; }
if (!response.ok || !result.ok) throw new Error(result.error || 'Koneksi terputus.');
if (Number.isFinite(result.remaining)) end = performance.now() + result.remaining * 1000;
if (action === 'save') { dirty = revision !== snapshotRevision; say('Draft tersimpan di server · Waktu tetap berjalan.'); }
} catch (error) { dirty = true; say('Belum tersinkron. Periksa koneksi; waktu server tetap berjalan.', true); }
};
// Serialize requests so an older draft cannot overwrite a newer one.
const enqueue = (action, eventType = '') => { pending++; queue = queue.then(() => send(action, eventType)).finally(() => { pending--; }); return queue; };
const log = eventType => {
if (closed || submitting) return;
const data = new FormData();
['csrf','phase','attempt_token'].forEach(name => data.set(name, exam.elements.namedItem(name).value));
data.set('action','event'); data.set('event',eventType);
navigator.sendBeacon(exam.action, data);
};
exam.addEventListener('input', () => { dirty = true; revision++; updateProgress(); });
document.addEventListener('visibilitychange', () => {
if (document.hidden) log('tab_hidden');
else { say('Anda kembali ke halaman tes. Waktu tetap berjalan; perpindahan halaman dicatat untuk peninjauan.'); enqueue('event','page_resumed'); }
});
window.addEventListener('blur', () => log('window_blur'));
['copy','cut','paste','contextmenu'].forEach(type => exam.addEventListener(type, () => log(type)));
exam.addEventListener('submit', async event => {
event.preventDefault();
if (closed || submitting) return;
if (performance.now() < end && !window.confirm('Kirim jawaban fase ini? Jawaban yang dikirim tidak dapat diulang.')) return;
submitting = true;
exam.querySelector('button[type=submit]').disabled = true;
await queue;
if (!closed) HTMLFormElement.prototype.submit.call(exam);
});
const tick = () => {
const remaining = Math.max(0, Math.ceil((end - performance.now()) / 1000));
document.querySelector('#exam-time').textContent = `${String(Math.floor(remaining / 60)).padStart(2,'0')}:${String(remaining % 60).padStart(2,'0')}`;
timer.classList.toggle('timer-warning', remaining <= 60);
if (!remaining && !closed && !submitting) exam.requestSubmit();
};
// Sync even without edits to detect server expiry or another active tab.
setInterval(() => { if (!closed && !submitting && pending === 0) enqueue(dirty ? 'save' : 'event'); }, 3000);
updateProgress(); tick(); setInterval(tick, 1000); enqueue('save');
}