/** * Local-only progress tracker. * * Stores everything in the user's own browser (localStorage). No network, * no account, no server. Data never leaves the device. * * Schema (versioned so we can migrate later without nuking users): * * aifs:progress:v1 = { * lessons: { * "": { * answers: { "": { picked: number, correct: boolean, t: number } }, * completedAt: number | null, * visitedAt: number * } * }, * updatedAt: number * } * * "" matches the path used in lesson.html?path=... and in * data.js urls (e.g. "phases/00-setup-and-tooling/01-dev-environment"). * * "" is "-q" e.g. "pre-q0", to match the quiz renderer. */ (function () { var STORAGE_KEY = 'aifs:progress:v1'; var listeners = []; function emptyState() { return { lessons: {}, updatedAt: 0 }; } function read() { try { var raw = localStorage.getItem(STORAGE_KEY); if (!raw) return emptyState(); var parsed = JSON.parse(raw); if (!parsed || typeof parsed !== 'object' || !parsed.lessons) return emptyState(); return parsed; } catch (e) { return emptyState(); } } function write(state) { state.updatedAt = Date.now(); try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) { // quota or disabled storage; fail silently } for (var i = 0; i < listeners.length; i++) { try { listeners[i](state); } catch (_) {} } } function ensureLesson(state, path) { if (!state.lessons[path]) { state.lessons[path] = { answers: {}, completedAt: null, visitedAt: 0 }; } return state.lessons[path]; } function recordVisit(path) { if (!path) return; var state = read(); var lesson = ensureLesson(state, path); lesson.visitedAt = Date.now(); write(state); } function recordAnswer(path, qid, picked, correct) { if (!path || !qid) return; var state = read(); var lesson = ensureLesson(state, path); lesson.answers[qid] = { picked: picked, correct: !!correct, t: Date.now() }; write(state); } function markLessonComplete(path) { if (!path) return; var state = read(); var lesson = ensureLesson(state, path); if (!lesson.completedAt) { lesson.completedAt = Date.now(); write(state); } } function unmarkLessonComplete(path) { if (!path) return; var state = read(); if (state.lessons[path] && state.lessons[path].completedAt) { state.lessons[path].completedAt = null; write(state); } } function getLessonProgress(path) { if (!path) return null; var state = read(); return state.lessons[path] || { answers: {}, completedAt: null, visitedAt: 0 }; } function isLessonComplete(path) { var lp = getLessonProgress(path); return !!(lp && lp.completedAt); } /** * Given a list of lesson urls (full GitHub urls from data.js), count how * many the user has completed. Match by the trailing "phases/.../..." path. */ function countCompletedFromUrls(urls) { var state = read(); var n = 0; for (var i = 0; i < urls.length; i++) { var path = extractPath(urls[i]); if (path && state.lessons[path] && state.lessons[path].completedAt) n++; } return n; } function extractPath(url) { if (!url) return ''; var m = String(url).match(/(phases\/[^/]+\/[^/]+)\/?/); return m ? m[1] : ''; } function totalCompleted() { var state = read(); var n = 0; for (var k in state.lessons) { if (state.lessons[k].completedAt) n++; } return n; } function reset() { try { localStorage.removeItem(STORAGE_KEY); } catch (e) {} for (var i = 0; i < listeners.length; i++) { try { listeners[i](emptyState()); } catch (_) {} } } function onChange(fn) { if (typeof fn === 'function') listeners.push(fn); } // Cross-tab sync: if user clears or updates progress in another tab, // refresh listeners here too. window.addEventListener('storage', function (e) { if (e.key !== STORAGE_KEY) return; var state = read(); for (var i = 0; i < listeners.length; i++) { try { listeners[i](state); } catch (_) {} } }); window.AIFSProgress = { recordVisit: recordVisit, recordAnswer: recordAnswer, markLessonComplete: markLessonComplete, unmarkLessonComplete: unmarkLessonComplete, getLessonProgress: getLessonProgress, isLessonComplete: isLessonComplete, countCompletedFromUrls: countCompletedFromUrls, extractPath: extractPath, totalCompleted: totalCompleted, reset: reset, onChange: onChange, }; })();