From 3c43c10184158272666377a3efc97bef88109555 Mon Sep 17 00:00:00 2001 From: Aditya8369 Date: Thu, 25 Jun 2026 22:59:47 +0530 Subject: [PATCH] Offline-first support for quizzes --- mathsquiz/calculusquiz.html | 12 ++ mathsquiz/geometryquiz.html | 12 ++ mathsquiz/probabilityquiz.html | 12 ++ mathsquiz/vectorquiz.html | 12 ++ offlineSync.js | 366 +++++++++++++++++++++++++++++++++ quiz/motionquiz.html | 1 + quiz/nlmquiz.html | 1 + quiz/projectilequiz.html | 11 + quiz/rayquiz.html | 12 ++ quizProgress.js | 36 +++- sw.js | 47 ++++- 11 files changed, 518 insertions(+), 4 deletions(-) create mode 100644 offlineSync.js diff --git a/mathsquiz/calculusquiz.html b/mathsquiz/calculusquiz.html index 90df9cc..0e9671e 100644 --- a/mathsquiz/calculusquiz.html +++ b/mathsquiz/calculusquiz.html @@ -51,10 +51,22 @@

🎉 Quiz Completed!

+ + + \ No newline at end of file diff --git a/mathsquiz/geometryquiz.html b/mathsquiz/geometryquiz.html index d145636..19e4865 100644 --- a/mathsquiz/geometryquiz.html +++ b/mathsquiz/geometryquiz.html @@ -51,10 +51,22 @@

🎉 Quiz Completed!

+ + + \ No newline at end of file diff --git a/mathsquiz/probabilityquiz.html b/mathsquiz/probabilityquiz.html index 4d2bcbf..b503617 100644 --- a/mathsquiz/probabilityquiz.html +++ b/mathsquiz/probabilityquiz.html @@ -51,10 +51,22 @@

🎉 Quiz Completed!

+ + + \ No newline at end of file diff --git a/mathsquiz/vectorquiz.html b/mathsquiz/vectorquiz.html index 9eb198d..126911c 100644 --- a/mathsquiz/vectorquiz.html +++ b/mathsquiz/vectorquiz.html @@ -51,10 +51,22 @@

🎉 Quiz Completed!

+ + + \ No newline at end of file diff --git a/offlineSync.js b/offlineSync.js new file mode 100644 index 0000000..a98f4d5 --- /dev/null +++ b/offlineSync.js @@ -0,0 +1,366 @@ +/** + * offlineSync.js — LearnSphere Offline Sync Manager + * + * Queues quiz progress updates when offline and flushes them on reconnect. + * Currently the app is localStorage-only; the queue is structured so a future + * backend can POST each entry to /api/sync-progress without changes. + * + * Usage: Include this script BEFORE quizProgress.js on quiz pages. + * + * Public API (via window.offlineSync): + * queueProgressUpdate(type, payload) — add an item to the offline queue + * flushQueue() — replay queued items and clear + * getQueueLength() — pending items count + * isOnline() — navigator.onLine wrapper + */ + +(function () { + "use strict"; + + const QUEUE_KEY = "learnsphere_offline_queue_v1"; + const DEDUP_WINDOW_MS = 2000; // ignore duplicate entries within 2 s + + // ── Queue Persistence ───────────────────────────────────────────────────── + + function _loadQueue() { + try { + const raw = localStorage.getItem(QUEUE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + + function _saveQueue(queue) { + try { + localStorage.setItem(QUEUE_KEY, JSON.stringify(queue)); + } catch (e) { + console.warn("LearnSphere: Could not persist offline queue.", e); + } + } + + // ── Public API ──────────────────────────────────────────────────────────── + + /** + * Queue a progress update for future sync. + * @param {"quiz_attempt"|"retry_attempt"} type + * @param {Object} payload — arguments originally passed to recordAttempt / recordRetryAttempt + */ + function queueProgressUpdate(type, payload) { + const queue = _loadQueue(); + const now = Date.now(); + + // Basic dedup: skip if an identical-looking entry was queued very recently + const isDuplicate = queue.some( + (item) => + item.type === type && + item.payload && + item.payload.topicId === payload.topicId && + item.payload.quizId === payload.quizId && + Math.abs(now - item.queuedAt) < DEDUP_WINDOW_MS + ); + if (isDuplicate) return; + + queue.push({ + id: _uid(), + type, + payload, + queuedAt: now, + synced: false, + }); + + // Keep queue bounded (max 200 entries) + if (queue.length > 200) { + queue.splice(0, queue.length - 200); + } + + _saveQueue(queue); + _dispatchQueueChange(queue.length); + } + + /** + * Flush all queued progress updates. + * Since the app currently uses localStorage only, flushing means marking + * entries as synced (the data is already saved locally by quizProgress.js). + * When a backend is added, each entry can be POSTed here before marking synced. + */ + function flushQueue() { + const queue = _loadQueue(); + if (queue.length === 0) return; + + let flushedCount = 0; + + for (const item of queue) { + if (item.synced) continue; + + try { + // Future: POST to /api/sync-progress + // For now, the data is already in localStorage via quizProgress.js. + // We just mark the entry as synced. + item.synced = true; + flushedCount++; + } catch (e) { + console.warn("LearnSphere: Failed to sync queued item:", item.id, e); + // Leave unsynced so it retries next time + } + } + + // Remove synced items + const remaining = queue.filter((item) => !item.synced); + _saveQueue(remaining); + + if (flushedCount > 0) { + console.info(`LearnSphere: Synced ${flushedCount} queued progress update(s).`); + _dispatchSyncComplete(flushedCount); + } + + _dispatchQueueChange(remaining.length); + } + + /** @returns {number} Number of pending (unsynced) items */ + function getQueueLength() { + const queue = _loadQueue(); + return queue.filter((item) => !item.synced).length; + } + + /** @returns {boolean} Current network status */ + function isOnline() { + return navigator.onLine !== false; + } + + // ── Internal Helpers ────────────────────────────────────────────────────── + + function _uid() { + return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); + } + + function _dispatchQueueChange(pendingCount) { + try { + window.dispatchEvent( + new CustomEvent("learnsphere:queue-change", { + detail: { pendingCount }, + }) + ); + } catch { + // Ignore if CustomEvent is unsupported + } + } + + function _dispatchSyncComplete(syncedCount) { + try { + window.dispatchEvent( + new CustomEvent("learnsphere:sync-complete", { + detail: { syncedCount }, + }) + ); + } catch { + // Ignore + } + } + + // ── Auto-sync on Reconnect ──────────────────────────────────────────────── + + function _onOnline() { + console.info("LearnSphere: Back online — flushing offline queue."); + flushQueue(); + + // Request Background Sync if available (for reliability) + if ("serviceWorker" in navigator && "SyncManager" in window) { + navigator.serviceWorker.ready + .then((reg) => reg.sync.register("learnsphere-sync-progress")) + .catch(() => { + // Background Sync not available; manual flush above covers it + }); + } + } + + window.addEventListener("online", _onOnline); + + // Also flush on page load if online and there are queued items + function _initFlush() { + if (isOnline() && getQueueLength() > 0) { + flushQueue(); + } + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", _initFlush); + } else { + _initFlush(); + } + + // ── Offline Status Bar UI Helper ────────────────────────────────────────── + + /** + * Injects an offline/online status bar into the quiz page. + * Call once on DOMContentLoaded. The bar auto-updates on connectivity changes. + */ + function initOfflineStatusBar() { + const container = document.querySelector(".container"); + if (!container) return; + + // Create status bar element + const bar = document.createElement("div"); + bar.id = "offline-status-bar"; + bar.setAttribute("role", "status"); + bar.setAttribute("aria-live", "polite"); + bar.style.cssText = [ + "display: none", + "padding: 10px 16px", + "border-radius: 10px", + "margin-bottom: 16px", + "font-size: 0.88rem", + "font-weight: 600", + "font-family: 'Poppins', 'Inter', sans-serif", + "transition: all 0.3s ease", + "position: relative", + "overflow: hidden", + ].join(";"); + + // Insert as first child of .container + container.insertBefore(bar, container.firstChild); + + // Inject animation styles + if (!document.getElementById("offline-status-styles")) { + const style = document.createElement("style"); + style.id = "offline-status-styles"; + style.textContent = ` + @keyframes offlineSlideDown { + from { transform: translateY(-20px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } + } + @keyframes offlinePulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.3); } + 50% { box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); } + } + @keyframes syncPulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.3); } + 50% { box-shadow: 0 0 0 6px rgba(34, 197, 94, 0); } + } + #offline-status-bar.offline-active { + display: flex !important; + align-items: center; + gap: 10px; + background: linear-gradient(135deg, rgba(239, 68, 68, 0.15), rgba(239, 68, 68, 0.08)); + border: 1px solid rgba(239, 68, 68, 0.3); + color: #fca5a5; + animation: offlineSlideDown 0.3s ease-out, offlinePulse 2s ease-in-out 3; + } + #offline-status-bar.sync-success { + display: flex !important; + align-items: center; + gap: 10px; + background: linear-gradient(135deg, rgba(34, 197, 94, 0.15), rgba(34, 197, 94, 0.08)); + border: 1px solid rgba(34, 197, 94, 0.3); + color: #86efac; + animation: offlineSlideDown 0.3s ease-out, syncPulse 1.5s ease-in-out 2; + } + #offline-status-bar .queue-badge { + background: rgba(255, 255, 255, 0.15); + padding: 2px 8px; + border-radius: 12px; + font-size: 0.78rem; + margin-left: auto; + } + #offline-status-bar .dismiss-btn { + background: none; + border: none; + color: inherit; + cursor: pointer; + font-size: 1.1rem; + padding: 0 4px; + margin-left: 8px; + opacity: 0.7; + transition: opacity 0.2s; + } + #offline-status-bar .dismiss-btn:hover { + opacity: 1; + } + `; + document.head.appendChild(style); + } + + let syncDismissTimer = null; + + function updateBar() { + const online = isOnline(); + const pending = getQueueLength(); + + if (!online) { + // Offline state + bar.className = "offline-active"; + bar.innerHTML = ` + 📴 You're offline — your quiz works normally. Progress is saved locally. + ${pending > 0 ? `${pending} pending` : ""} + `; + if (syncDismissTimer) { + clearTimeout(syncDismissTimer); + syncDismissTimer = null; + } + } else if (bar.classList.contains("offline-active")) { + // Just came back online + bar.className = "sync-success"; + bar.innerHTML = ` + ✅ Back online — progress synced! + + `; + bar.querySelector(".dismiss-btn").addEventListener("click", () => { + bar.className = ""; + bar.style.display = "none"; + }); + // Auto-dismiss after 5 seconds + syncDismissTimer = setTimeout(() => { + bar.className = ""; + bar.style.display = "none"; + }, 5000); + } + } + + // Wire up events + window.addEventListener("online", updateBar); + window.addEventListener("offline", updateBar); + window.addEventListener("learnsphere:sync-complete", () => { + if (isOnline()) { + bar.className = "sync-success"; + bar.innerHTML = ` + ✅ Back online — progress synced! + + `; + bar.querySelector(".dismiss-btn").addEventListener("click", () => { + bar.className = ""; + bar.style.display = "none"; + }); + if (syncDismissTimer) clearTimeout(syncDismissTimer); + syncDismissTimer = setTimeout(() => { + bar.className = ""; + bar.style.display = "none"; + }, 5000); + } + }); + window.addEventListener("learnsphere:queue-change", (e) => { + if (!isOnline()) updateBar(); + }); + + // Initial check + updateBar(); + } + + // Auto-init the status bar when DOM is ready + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initOfflineStatusBar); + } else { + initOfflineStatusBar(); + } + + // ── Export ───────────────────────────────────────────────────────────────── + + window.offlineSync = { + queueProgressUpdate, + flushQueue, + getQueueLength, + isOnline, + initOfflineStatusBar, + }; +})(); diff --git a/quiz/motionquiz.html b/quiz/motionquiz.html index 87f9718..d3ab2f6 100644 --- a/quiz/motionquiz.html +++ b/quiz/motionquiz.html @@ -62,6 +62,7 @@

🎉 Quiz Completed!

+ diff --git a/quiz/nlmquiz.html b/quiz/nlmquiz.html index 5f6150b..c7deaa8 100644 --- a/quiz/nlmquiz.html +++ b/quiz/nlmquiz.html @@ -64,6 +64,7 @@

🎉 Quiz Completed!

+ diff --git a/quiz/projectilequiz.html b/quiz/projectilequiz.html index f3d24a4..732c8e3 100644 --- a/quiz/projectilequiz.html +++ b/quiz/projectilequiz.html @@ -67,12 +67,23 @@

🎉 Quiz Completed!

+ + \ No newline at end of file diff --git a/quiz/rayquiz.html b/quiz/rayquiz.html index 205324f..f87cf25 100644 --- a/quiz/rayquiz.html +++ b/quiz/rayquiz.html @@ -67,11 +67,23 @@

🎉 Quiz Completed!

+ + + diff --git a/quizProgress.js b/quizProgress.js index 3701f2a..77a8908 100644 --- a/quizProgress.js +++ b/quizProgress.js @@ -367,6 +367,22 @@ function recordAttempt({ topicId, score, totalQuestions, correctCount, timeTaken // Update unified streak & daily goal state _updateUnifiedStreakAndGoal("quiz", 1); + // Queue for future backend sync when offline + if (window.offlineSync && typeof window.offlineSync.queueProgressUpdate === "function") { + if (!window.offlineSync.isOnline()) { + window.offlineSync.queueProgressUpdate("quiz_attempt", { + topicId, + score: got, + totalQuestions: total, + correctCount: correct, + timeTakenMs: timeMs, + quizId, + finishedAt: now, + practiceDate: today, + }); + } + } + return state; } @@ -682,6 +698,8 @@ window.quizProgress = { getQuestionWeaknessWeight, recordRetryAttempt, getAttemptsHistory, + /** @returns {Object|null} offlineSync reference */ + get offlineSync() { return window.offlineSync || null; }, }; @@ -715,7 +733,23 @@ function recordRetryAttempt({ topicId, results }) { }); _saveState(state); -} + // Queue for future backend sync when offline + if (window.offlineSync && typeof window.offlineSync.queueProgressUpdate === "function") { + if (!window.offlineSync.isOnline()) { + window.offlineSync.queueProgressUpdate("retry_attempt", { topicId, results }); + } + } +} +// Listen for Background Sync flush messages from service worker +if ("serviceWorker" in navigator) { + navigator.serviceWorker.addEventListener("message", (event) => { + if (event.data && event.data.action === "do-flush-offline-queue") { + if (window.offlineSync && typeof window.offlineSync.flushQueue === "function") { + window.offlineSync.flushQueue(); + } + } + }); +} diff --git a/sw.js b/sw.js index 8ea9bbe..f639c86 100644 --- a/sw.js +++ b/sw.js @@ -4,7 +4,7 @@ */ // Increment this value to invalidate older caches -const CACHE_VERSION = "v2"; +const CACHE_VERSION = "v3"; const CACHE_NAME = `learnsphere-static-${CACHE_VERSION}`; // For runtime image caching @@ -42,6 +42,7 @@ const APP_SHELL_URLS = [ "/chemistryquiz/equilibriumquiz.js", "/chemistryquiz/thermoquiz.js", "/quizAssignmentHelper.js", + "/offlineSync.js", // Quizzes styles "/quiz/motionquiz.css", @@ -84,6 +85,14 @@ const APP_SHELL_URLS = [ "/progress.js", "/review.js", "/quizProgress.js", + "/badges.js", + "/badges.css", + "/exportProgress.js", + "/dashboardProgress.js", + + // Data + "/quiz/bank/physics-motion.json", + "/manifest.json", // Images "/student.png", @@ -120,9 +129,10 @@ self.addEventListener("activate", (event) => { (async () => { // Remove old caches const keys = await caches.keys(); + const keepCaches = new Set([CACHE_NAME, RUNTIME_IMAGE_CACHE_NAME]); await Promise.all( keys.map((k) => { - if (k !== CACHE_NAME) return caches.delete(k); + if (!keepCaches.has(k)) return caches.delete(k); }) ); self.clients.claim(); @@ -310,7 +320,13 @@ self.addEventListener("message", (event) => { "/sub/maths.html", "/sub/physics.html", "/sub/chemistry.html", - "/sub/biology.html" + "/sub/biology.html", + + // Quiz data + "/quiz/bank/physics-motion.json", + + // Offline sync module + "/offlineSync.js" ]; try { @@ -331,5 +347,30 @@ self.addEventListener("message", (event) => { })() ); } + + // Handle manual sync flush request from clients + if (event.data && event.data.action === "flush-offline-queue") { + // Notify all clients to flush their offline queues + event.waitUntil( + self.clients.matchAll().then((clientsList) => { + clientsList.forEach((client) => { + client.postMessage({ action: "do-flush-offline-queue" }); + }); + }) + ); + } +}); + +// Background Sync: flush offline queue when connectivity returns +self.addEventListener("sync", (event) => { + if (event.tag === "learnsphere-sync-progress") { + event.waitUntil( + self.clients.matchAll().then((clientsList) => { + clientsList.forEach((client) => { + client.postMessage({ action: "do-flush-offline-queue" }); + }); + }) + ); + } });