(function () { var config = window.VUIAttendanceOfflineConfig || {}; if (!config.enabled) return; var root = document.querySelector('[data-offline-attendance="1"]'); if (!root) return; var STORAGE_KEY = 'vuimedia_attendance_offline_state_v1'; var DEVICE_KEY = 'vuimedia_attendance_offline_device_uuid_v1'; var STUDENT_DEVICE_KEY = 'vuimedia_attendance_device_id'; var state = loadState(); var syncTimer = null; var els = { status: root.querySelector('.js-offline-connection-status'), deviceId: root.querySelector('.js-offline-device-id'), lastRefresh: root.querySelector('.js-offline-last-refresh'), pendingCount: root.querySelector('.js-offline-pending-count'), lastSync: root.querySelector('.js-offline-last-sync'), refreshBtn: root.querySelector('.js-offline-refresh'), syncBtn: root.querySelector('.js-offline-sync'), sessionSelect: root.querySelector('.js-offline-session'), studentSearch: root.querySelector('.js-offline-student-search'), students: root.querySelector('.js-offline-students'), log: root.querySelector('.js-offline-log') }; if (!state.deviceUuid) { state.deviceUuid = readOrCreateDeviceUuid(); persistState(); } function loadState() { try { var raw = window.localStorage.getItem(STORAGE_KEY); if (!raw) { return { deviceUuid: '', bootstrap: null, pendingEvents: [], history: [], lastBootstrapAt: '', lastSyncAt: '', lastSyncSummary: '' }; } var parsed = JSON.parse(raw); return { deviceUuid: String(parsed.deviceUuid || ''), bootstrap: parsed.bootstrap && typeof parsed.bootstrap === 'object' ? parsed.bootstrap : null, pendingEvents: Array.isArray(parsed.pendingEvents) ? parsed.pendingEvents : [], history: Array.isArray(parsed.history) ? parsed.history : [], lastBootstrapAt: String(parsed.lastBootstrapAt || ''), lastSyncAt: String(parsed.lastSyncAt || ''), lastSyncSummary: String(parsed.lastSyncSummary || '') }; } catch (error) { return { deviceUuid: '', bootstrap: null, pendingEvents: [], history: [], lastBootstrapAt: '', lastSyncAt: '', lastSyncSummary: '' }; } } function persistState() { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } function readOrCreateDeviceUuid() { try { var existing = String(window.localStorage.getItem(DEVICE_KEY) || '').trim(); if (existing) { return existing; } var next = createUuid(); window.localStorage.setItem(DEVICE_KEY, next); return next; } catch (error) { return createUuid(); } } function createUuid() { if (window.crypto && typeof window.crypto.randomUUID === 'function') { return window.crypto.randomUUID(); } return 'offline-' + Date.now() + '-' + Math.random().toString(16).slice(2, 12); } function nowIso() { return new Date().toISOString(); } function formatTime(value) { if (!value) return 'Not yet synced'; var date = new Date(value); if (isNaN(date.getTime())) return value; return date.toLocaleString(); } function log(message, isError) { if (!els.log) return; var item = document.createElement('div'); item.className = 'a-offline-logitem' + (isError ? ' a-offline-logitem--error' : ''); item.textContent = message; els.log.prepend(item); while (els.log.children.length > 8) { els.log.removeChild(els.log.lastChild); } } function setStatus(text, mode) { if (!els.status) return; els.status.textContent = text; els.status.className = 'a-offline-status js-offline-connection-status'; if (mode === 'warn') { els.status.classList.add('a-offline-status--warn'); } else if (mode === 'error') { els.status.classList.add('a-offline-status--error'); } } function deviceMeta() { return { device_name: config.userLabel + ' device', platform_name: navigator.platform || navigator.userAgent || 'browser', app_version: 'attendance-offline-v1' }; } function apiUrl(action) { return config.endpoint + '?action=' + encodeURIComponent(action); } function isStudentMode() { return String(config.role || '').toLowerCase() === 'student'; } function readCookieValue(key) { var cookies = String(document.cookie || '').split(';'); for (var i = 0; i < cookies.length; i += 1) { var part = cookies[i].trim(); if (part.indexOf(key + '=') === 0) { return decodeURIComponent(part.substring(key.length + 1)); } } return ''; } function writeCookieValue(key, value) { document.cookie = key + '=' + encodeURIComponent(value) + '; path=/; max-age=' + String(60 * 60 * 24 * 365 * 2) + '; SameSite=Lax'; } function createStudentDeviceIdentifier() { if (window.crypto && typeof window.crypto.randomUUID === 'function') { return 'dv-' + window.crypto.randomUUID().replace(/[^a-zA-Z0-9]/g, ''); } return 'dv-' + String(Date.now()) + String(Math.random()).replace(/[^0-9]/g, '').slice(0, 18); } function readStudentDeviceIdentifier() { try { var existing = String(window.localStorage.getItem(STUDENT_DEVICE_KEY) || '').trim(); if (existing) { writeCookieValue(STUDENT_DEVICE_KEY, existing); return existing; } existing = String(readCookieValue(STUDENT_DEVICE_KEY) || '').trim(); if (existing) { window.localStorage.setItem(STUDENT_DEVICE_KEY, existing); return existing; } var next = createStudentDeviceIdentifier(); window.localStorage.setItem(STUDENT_DEVICE_KEY, next); writeCookieValue(STUDENT_DEVICE_KEY, next); return next; } catch (error) { var fallback = String(readCookieValue(STUDENT_DEVICE_KEY) || '').trim(); if (fallback) return fallback; fallback = createStudentDeviceIdentifier(); writeCookieValue(STUDENT_DEVICE_KEY, fallback); return fallback; } } function readStudentGeo() { return new Promise(function (resolve, reject) { if (!navigator.geolocation || typeof navigator.geolocation.getCurrentPosition !== 'function') { reject(new Error('Location access is required for offline student attendance.')); return; } navigator.geolocation.getCurrentPosition(function (position) { var coords = position && position.coords ? position.coords : {}; if (typeof coords.latitude !== 'number' || typeof coords.longitude !== 'number') { reject(new Error('Location access is required for offline student attendance.')); return; } resolve({ lat: Number(coords.latitude), lng: Number(coords.longitude), accuracy: typeof coords.accuracy === 'number' ? Number(coords.accuracy) : null }); }, function (error) { var code = Number(error && error.code || 0); if (code === 1) { reject(new Error('Allow location access before queueing offline student attendance.')); return; } reject(new Error('Unable to verify your location for offline student attendance.')); }, { enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }); }); } function csrfToken() { return String(config.csrfToken || ''); } function requestBootstrap() { var url = apiUrl('bootstrap') + '&device_uuid=' + encodeURIComponent(state.deviceUuid) + '&device_name=' + encodeURIComponent(deviceMeta().device_name) + '&platform_name=' + encodeURIComponent(deviceMeta().platform_name) + '&app_version=' + encodeURIComponent(deviceMeta().app_version) + '&_ts=' + encodeURIComponent(String(Date.now())); return fetch(url, { cache: 'no-store', credentials: 'same-origin', headers: { 'Accept': 'application/json' } }).then(readJson).then(function (payload) { if (!payload.ok) { throw new Error(payload.message || payload.raw_text || 'Offline bootstrap failed.'); } state.bootstrap = payload.data || null; state.lastBootstrapAt = nowIso(); persistState(); window.VUIAttendanceOfflineLastBootstrap = payload.data || null; return payload; }); } function pushPendingEvents() { if (!state.pendingEvents.length) { render(); return Promise.resolve(); } if (!navigator.onLine) { setStatus('Offline. Pending marks will sync when internet returns.', 'warn'); return Promise.resolve(); } var events = state.pendingEvents.slice(); var payload = { device_uuid: state.deviceUuid, batch_uuid: createUuid(), events: events, device_name: deviceMeta().device_name, platform_name: deviceMeta().platform_name, app_version: deviceMeta().app_version, csrf_token: csrfToken() }; setStatus('Syncing pending offline marks...', 'warn'); return fetch(apiUrl('push'), { method: 'POST', cache: 'no-store', credentials: 'same-origin', headers: { 'Content-Type': 'application/json; charset=UTF-8', 'Accept': 'application/json', 'X-CSRF-Token': csrfToken() }, body: JSON.stringify(payload) }).then(readJson).then(function (response) { if (!response.ok) { throw new Error(response.message || 'Offline sync failed.'); } var data = response.data || {}; var results = Array.isArray(data.results) ? data.results : []; if (results.length) { var resultMap = {}; results.forEach(function (item) { resultMap[String(item.event_uuid || '')] = item; }); state.pendingEvents = state.pendingEvents.filter(function (event) { var eventId = String(event.event_uuid || ''); var result = resultMap[eventId]; if (!result) return true; state.history.unshift({ event_uuid: eventId, status: String(result.status || 'accepted'), message: String(result.message || ''), student_user_id: Number(event.student_user_id || 0), lecture_session_id: Number(event.lecture_session_id || 0), marked_at_device: String(event.marked_at_device || ''), synced_at: nowIso() }); return false; }); } else { state.pendingEvents = []; } state.history = state.history.slice(0, 80); state.lastSyncAt = nowIso(); state.lastSyncSummary = summarizeSync(data.summary || {}); persistState(); render(); if (navigator.onLine) { requestBootstrap().then(render).catch(function () { render(); }); } log(state.lastSyncSummary || 'Offline marks synced.', false); logProblemSyncResults(results, events); }).catch(function (error) { var details = explainRequestError(error); setStatus(error.message || 'Sync failed.', 'error'); log(details, true); render(); }); } function summarizeSync(summary) { var accepted = Number(summary.accepted_events || 0); var duplicate = Number(summary.duplicate_events || 0); var rejected = Number(summary.rejected_events || 0); var processed = Number(summary.processed_events || 0); return processed + ' processed, ' + accepted + ' accepted, ' + duplicate + ' duplicate, ' + rejected + ' rejected'; } function syncResultReason(syncResult) { if (!syncResult) return ''; return String(syncResult.message || syncResult.rejection_reason || '').trim(); } function syncResolutionHint(reason) { var text = String(reason || '').toLowerCase(); if (!text) return 'Retry after refreshing the offline copy.'; if (text.indexOf('not registered') !== -1) { return 'Register the student for the course in the active session/semester, then refresh the offline copy.'; } if (text.indexOf('window closed') !== -1) { return 'Start or reuse an active session and refresh the offline copy before marking.'; } if (text.indexOf('lecture location') !== -1 || text.indexOf('location') !== -1 || text.indexOf('distance') !== -1) { return 'Move within the lecture venue range or ask admin to confirm the venue coordinates/radius.'; } if (text.indexOf('device') !== -1 || text.indexOf('browser') !== -1) { return 'Use the registered attendance device/browser or ask admin to reset the device binding.'; } if (text.indexOf('already exists') !== -1 || text.indexOf('duplicate') !== -1) { return 'No action needed if this mark is already present in the attendance record.'; } if (text.indexOf('not available') !== -1 || text.indexOf('not found') !== -1) { return 'Refresh the offline copy while online, then try again with the current session.'; } return 'Resolve the reason above, refresh the offline copy, then retry.'; } function sessionLabelForEvent(event) { var sessionId = Number(event && event.lecture_session_id || 0); var session = getSessions().find(function (item) { return Number(item.id || 0) === sessionId; }); if (!session) return 'session #' + String(sessionId || '-'); var label = String(session.course_code || '') + ' - ' + String(session.course_title || ''); label = label.trim() || ('session #' + String(sessionId || '-')); return label + ' (' + String(session.session_date || '-') + ')'; } function studentLabelForEvent(event) { var sessionId = Number(event && event.lecture_session_id || 0); var studentId = Number(event && event.student_user_id || 0); var roster = getRosterForSession(sessionId); var row = roster.find(function (item) { return Number(item.student_user_id || 0) === studentId; }); if (!row) return isStudentMode() ? 'Your mark' : 'student #' + String(studentId || '-'); var code = String(row.student_id || row.registration_no || '').trim(); return String(row.full_name || (isStudentMode() ? 'Your mark' : 'Student')) + (code ? ' [' + code + ']' : ''); } function logProblemSyncResults(results, events) { if (!Array.isArray(results) || !results.length) return; var eventMap = {}; (Array.isArray(events) ? events : []).forEach(function (event) { eventMap[String(event.event_uuid || '')] = event; }); results.forEach(function (item) { var status = String(item.status || '').toLowerCase(); if (status !== 'rejected' && status !== 'duplicate') return; var event = eventMap[String(item.event_uuid || '')] || {}; var reason = syncResultReason(item) || (status === 'duplicate' ? 'Attendance already exists for this student and session.' : 'No reason supplied by server.'); var prefix = status === 'duplicate' ? 'Duplicate' : 'Rejected'; log(prefix + ': ' + studentLabelForEvent(event) + ' | ' + sessionLabelForEvent(event) + ' | Reason: ' + reason + ' | Fix: ' + syncResolutionHint(reason), true); }); } function readJson(response) { return response.text().then(function (text) { var data = {}; var trimmed = String(text || '').trim(); var contentType = String(response.headers.get('content-type') || '').toLowerCase(); if (text) { try { data = JSON.parse(text); } catch (error) { data = { ok: false, raw_text: trimmed.slice(0, 500), response_status: Number(response.status || 0), response_url: String(response.url || ''), response_content_type: contentType }; if (trimmed) { if (trimmed.charAt(0) === '<' || contentType.indexOf('text/html') !== -1) { var preview = trimmed .replace(/