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'); }