From 7c9daa413c1d87af81a16237da6b90d75f9d056d Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Thu, 5 Jun 2025 23:31:13 +0200 Subject: [PATCH] feat: enhance LessonEngine to manage user progress and code storage --- src/app.js | 184 +++++++++++-------------------- src/impl/LessonEngine.js | 229 ++++++++++++++++++++++++++++++--------- 2 files changed, 241 insertions(+), 172 deletions(-) diff --git a/src/app.js b/src/app.js index ca70dc6..d73f2f6 100644 --- a/src/app.js +++ b/src/app.js @@ -1,16 +1,9 @@ import { LessonEngine } from "./impl/LessonEngine.js"; import { renderLesson, renderModuleList, renderLevelIndicator, showFeedback, updateActiveLessonInSidebar } from "./helpers/renderer.js"; -import { validateUserCode } from "./helpers/validator.js"; import { loadModules } from "./config/lessons.js"; -// Main Application state +// Simplified state - LessonEngine now manages lesson state and progress const state = { - currentModule: null, - currentLessonIndex: 0, - modules: [], - userCode: new Map(), // Store user code for each lesson - userProgress: {}, // Format: { moduleId: { completed: [0, 2, 3], current: 4 } } - userCodeBeforeValidation: "", // Track user code state before validation userSettings: { disableFeedbackErrors: false } @@ -44,7 +37,7 @@ const elements = { disableFeedbackToggle: document.getElementById("disable-feedback-toggle") }; -// Initialize the lesson engine +// Initialize the lesson engine - now the single source of truth const lessonEngine = new LessonEngine(); // Load user progress from localStorage @@ -89,17 +82,20 @@ function initFeedbackToggle() { // Initialize the module list async function initializeModules() { try { - state.modules = await loadModules(); + const modules = await loadModules(); + lessonEngine.setModules(modules); // Use the new renderModuleList function with both callbacks - renderModuleList(elements.moduleList, state.modules, selectModule, selectLesson); + renderModuleList(elements.moduleList, modules, selectModule, selectLesson); - // Select the first module or the last one user was on - const lastModuleId = localStorage.getItem("codeCrispies.lastModuleId"); - if (lastModuleId && state.modules.find((m) => m.id === lastModuleId)) { + // Load saved progress and select appropriate module + const progressData = lessonEngine.loadUserProgress(); + const lastModuleId = progressData?.lastModuleId; + + if (lastModuleId && modules.find(m => m.id === lastModuleId)) { selectModule(lastModuleId); - } else if (state.modules.length > 0) { - selectModule(state.modules[0].id); + } else if (modules.length > 0) { + selectModule(modules[0].id); } // Update progress indicator on module selector button @@ -112,21 +108,7 @@ async function initializeModules() { // Update progress indicator on module selector button function updateModuleSelectorButtonProgress() { - if (!state.modules.length) return; - - // Calculate overall progress across all modules - let totalLessons = 0; - let totalCompleted = 0; - - state.modules.forEach((module) => { - totalLessons += module.lessons.length; - const progress = state.userProgress[module.id]; - if (progress && progress.completed) { - totalCompleted += progress.completed.length; - } - }); - - const percentComplete = totalLessons > 0 ? Math.round((totalCompleted / totalLessons) * 100) : 0; + const stats = lessonEngine.getProgressStats(); // Create progress indicator const progressBar = document.createElement("div"); @@ -136,13 +118,13 @@ function updateModuleSelectorButtonProgress() { bottom: 0; left: 0; height: 3px; - width: ${percentComplete}%; + width: ${stats.percentComplete}%; background-color: var(--primary-light); border-radius: 0 3px 3px 0; `; // Add progress percentage text - elements.moduleSelectorBtn.innerHTML = `Progress ${percentComplete}%`; + elements.moduleSelectorBtn.innerHTML = `Progress ${stats.percentComplete}%`; elements.moduleSelectorBtn.style.position = "relative"; // Remove any existing progress bar before adding new one @@ -154,12 +136,10 @@ function updateModuleSelectorButtonProgress() { elements.moduleSelectorBtn.appendChild(progressBar); } -// Select a module +// Select a module - delegate to LessonEngine function selectModule(moduleId) { - const selectedModule = state.modules.find((module) => module.id === moduleId); - if (!selectedModule) return; - - state.currentModule = selectedModule; + const success = lessonEngine.setModuleById(moduleId); + if (!success) return; // Update module list UI to highlight the active module const moduleItems = elements.moduleList.querySelectorAll(".module-header"); @@ -170,35 +150,21 @@ function selectModule(moduleId) { } }); - // Load user progress for this module - if (!state.userProgress[moduleId]) { - state.userProgress[moduleId] = { completed: [], current: 0 }; - } - - state.currentLessonIndex = state.userProgress[moduleId].current || 0; loadCurrentLesson(); - // Save the last selected module - localStorage.setItem("codeCrispies.lastModuleId", moduleId); - // Reset any success indicators resetSuccessIndicators(); } function selectLesson(moduleId, lessonIndex) { // Select the module first if it's not already selected - if (!state.currentModule || state.currentModule.id !== moduleId) { - selectModule(moduleId); + const currentState = lessonEngine.getCurrentState(); + if (!currentState.module || currentState.module.id !== moduleId) { + lessonEngine.setModuleById(moduleId); } - // Update current lesson index - state.currentLessonIndex = lessonIndex; - - // Update user progress - state.userProgress[moduleId].current = lessonIndex; - saveUserProgress(); - - // Load the lesson + // Set the lesson + lessonEngine.setLessonByIndex(lessonIndex); loadCurrentLesson(); } @@ -229,22 +195,16 @@ function resetEditorLayout(lesson) { elements.validationIndicators.innerHTML = ""; } -// Load the current lesson +// Load the current lesson - now delegates to LessonEngine function loadCurrentLesson() { - if (!state.currentModule || !state.currentModule.lessons) { + const engineState = lessonEngine.getCurrentState(); + + if (!engineState.module || !engineState.lesson) { return; } - // Make sure lesson index is in bounds - if (state.currentLessonIndex >= state.currentModule.lessons.length) { - state.currentLessonIndex = state.currentModule.lessons.length - 1; - } else if (state.currentLessonIndex < 0) { - state.currentLessonIndex = 0; - } - - const lesson = state.currentModule.lessons[state.currentLessonIndex]; - const mode = lesson.mode || state.currentModule?.mode || "css"; - lessonEngine.setLesson(lesson); + const lesson = engineState.lesson; + const mode = lesson.mode || engineState.module?.mode || "css"; // Update UI based on mode updateEditorForMode(mode); @@ -264,12 +224,14 @@ function loadCurrentLesson() { lesson ); + // Set user code in input + elements.codeInput.value = engineState.userCode; + // Configure editor layout based on lesson settings resetEditorLayout(lesson); // Update Run button text based on completion status - const moduleProgress = state.userProgress[state.currentModule.id]; - if (moduleProgress && moduleProgress.completed.includes(state.currentLessonIndex)) { + if (engineState.isCompleted) { elements.runBtn.innerHTML = 'Re-run'; // Add completion badge next to title if not already present @@ -290,27 +252,20 @@ function loadCurrentLesson() { } // Update level indicator - renderLevelIndicator(elements.levelIndicator, state.currentLessonIndex + 1, state.currentModule.lessons.length); + renderLevelIndicator(elements.levelIndicator, engineState.lessonIndex + 1, engineState.totalLessons); // Update active lesson in sidebar - updateActiveLessonInSidebar(state.currentModule.id, state.currentLessonIndex); + updateActiveLessonInSidebar(engineState.module.id, engineState.lessonIndex); // Update navigation buttons updateNavigationButtons(); - // Save current progress - state.userProgress[state.currentModule.id].current = state.currentLessonIndex; - saveUserProgress(); - // Update progress indicator on module selector button updateModuleSelectorButtonProgress(); // Focus on the code editor by default elements.codeInput.focus(); - // Store current code - state.userCodeBeforeValidation = elements.codeInput.value; - // Track live changes and update preview when the user pauses typing setupLivePreview(); } @@ -334,19 +289,16 @@ function handleUserInput() { // Set a new timer for preview update after user stops typing previewTimer = setTimeout(() => { - // Apply the code for preview without validation - // lessonEngine.applyUserCode(elements.codeInput.value); runCode(); - }, 800); // Update preview 500ms after user stops typing - - // Store current code state - state.userCodeBeforeValidation = elements.codeInput.value; + }, 800); // Update preview 800ms after user stops typing } // Update navigation buttons state function updateNavigationButtons() { - elements.prevBtn.disabled = state.currentLessonIndex === 0; - elements.nextBtn.disabled = !state.currentModule || state.currentLessonIndex === state.currentModule.lessons.length - 1; + const engineState = lessonEngine.getCurrentState(); + + elements.prevBtn.disabled = !engineState.canGoPrev; + elements.nextBtn.disabled = !engineState.canGoNext; // Style changes for disabled buttons if (elements.prevBtn.disabled) { @@ -362,42 +314,36 @@ function updateNavigationButtons() { } } -// Go to the next lesson +// Go to the next lesson - delegate to LessonEngine function nextLesson() { - if (!state.currentModule) return; - - if (state.currentLessonIndex < state.currentModule.lessons.length - 1) { - state.currentLessonIndex++; + const success = lessonEngine.nextLesson(); + if (success) { loadCurrentLesson(); } } -// Go to the previous lesson +// Go to the previous lesson - delegate to LessonEngine function prevLesson() { - if (state.currentLessonIndex > 0) { - state.currentLessonIndex--; + const success = lessonEngine.previousLesson(); + if (success) { loadCurrentLesson(); } } -// Run the user code +// Run the user code - now uses LessonEngine validation function runCode() { const userCode = elements.codeInput.value; - const lesson = state.currentModule.lessons[state.currentLessonIndex]; // Rotate the Run button icon const runButtonImg = document.querySelector("#run-btn img"); const runButtonRotationDegree = Number(runButtonImg.style.transform.match(/\d+/)?.pop() ?? 0); document.querySelector("#run-btn img").style.transform = `rotate(${runButtonRotationDegree + 180}deg)`; - // Always apply the code to the preview, regardless of validation result + // Apply the code to the preview via LessonEngine lessonEngine.applyUserCode(userCode, true); - // Backup code in local storage - state.userCode.set(state.currentLessonIndex, userCode); - localStorage.setItem("codeCrispies.userCode", JSON.stringify(Array.from(state.userCode.entries()))); - - const validationResult = validateUserCode(userCode, lesson); + // Validate code using LessonEngine + const validationResult = lessonEngine.validateCode(); // Add validation indicators based on validCases count if available if (validationResult.validCases) { @@ -412,18 +358,10 @@ function runCode() { } if (validationResult.isValid) { - // Mark lesson as completed - const moduleProgress = state.userProgress[state.currentModule.id]; - if (!moduleProgress.completed.includes(state.currentLessonIndex)) { - moduleProgress.completed.push(state.currentLessonIndex); - saveUserProgress(); - updateModuleSelectorButtonProgress(); - } - // Show success feedback with visual indicators showFeedback(true, validationResult.message || "Great job! Your code works correctly."); - // Add this block to update the Run button to Re-run + // Update the Run button to Re-run elements.runBtn.innerHTML = 'Re-run'; elements.runBtn.classList.add("re-run"); @@ -441,11 +379,11 @@ function runCode() { elements.nextBtn.classList.add("success"); elements.taskInstruction.classList.add("success-instruction"); - // Enable the next button if not already on the last lesson - if (state.currentLessonIndex < state.currentModule.lessons.length - 1) { - elements.nextBtn.disabled = false; - elements.nextBtn.classList.remove("btn-disabled"); - } + // Update navigation buttons + updateNavigationButtons(); + + // Update progress indicator + updateModuleSelectorButtonProgress(); } else { // Reset any success indicators resetSuccessIndicators(); @@ -459,8 +397,11 @@ function runCode() { function showModuleSelector() { elements.modalTitle.textContent = "Select a Module"; + const engineState = lessonEngine.getCurrentState(); + const modules = lessonEngine.modules; + // Create module buttons - const moduleButtons = state.modules.map((module) => { + const moduleButtons = modules.map((module) => { const button = document.createElement("button"); button.classList.add("btn", "module-button"); button.style.display = "block"; @@ -469,9 +410,8 @@ function showModuleSelector() { button.style.padding = "15px"; button.style.textAlign = "left"; - // Add completion status - const progress = state.userProgress[module.id]; - const completedCount = progress ? progress.completed.length : 0; + // Add completion status using LessonEngine + const completedCount = lessonEngine.userProgress[module.id]?.completed.length || 0; const totalLessons = module.lessons.length; const percentComplete = Math.round((completedCount / totalLessons) * 100); diff --git a/src/impl/LessonEngine.js b/src/impl/LessonEngine.js index abfaf8c..8dfe0d7 100644 --- a/src/impl/LessonEngine.js +++ b/src/impl/LessonEngine.js @@ -1,6 +1,6 @@ /** * LessonEngine - Core class for managing lessons and applying/testing user code - * This file is the implementation of the LessonEngine class declaration from app.helpers + * Single source of truth for lesson state and progress */ import { validateUserCode } from "../helpers/validator.js"; import { showFeedback } from "../helpers/renderer.js"; @@ -12,6 +12,19 @@ export class LessonEngine { this.currentModule = null; this.currentLessonIndex = 0; this.lastRenderedCode = ""; // Track last applied code to prevent unnecessary re-renders + this.modules = []; + this.userProgress = {}; // Format: { moduleId: { completed: [0, 2, 3], current: 4 } } + this.userCodeMap = new Map(); // Store user code for each lesson + this.loadUserProgress(); + } + + /** + * Initialize with modules array + * @param {Array} modules - Available modules + */ + setModules(modules) { + this.modules = modules; + this.loadUserCodeFromStorage(); } /** @@ -21,9 +34,32 @@ export class LessonEngine { setModule(module) { this.currentModule = module; this.currentLessonIndex = 0; - if (module && module.lessons && module.lessons.length > 0) { - this.setLesson(module.lessons[0]); + + // Load user progress for this module + if (!this.userProgress[module.id]) { + this.userProgress[module.id] = { completed: [], current: 0 }; } + + this.currentLessonIndex = this.userProgress[module.id].current || 0; + + if (module && module.lessons && module.lessons.length > 0) { + this.setLesson(module.lessons[this.currentLessonIndex]); + } + + this.saveUserProgress(); + } + + /** + * Set module by ID + * @param {string} moduleId - The module ID + * @returns {boolean} Whether the operation was successful + */ + setModuleById(moduleId) { + const module = this.modules.find(m => m.id === moduleId); + if (!module) return false; + + this.setModule(module); + return true; } /** @@ -32,7 +68,11 @@ export class LessonEngine { */ setLesson(lesson) { this.currentLesson = lesson; - this.userCode = lesson.initialCode || ""; + + // Load saved user code for this lesson or use initial code + const lessonKey = `${this.currentModule.id}-${this.currentLessonIndex}`; + this.userCode = this.userCodeMap.get(lessonKey) || lesson.initialCode || ""; + this.lastRenderedCode = ""; // Reset last rendered code this.renderPreview(); } @@ -53,6 +93,11 @@ export class LessonEngine { this.currentLessonIndex = index; this.setLesson(this.currentModule.lessons[index]); + + // Update progress + this.userProgress[this.currentModule.id].current = index; + this.saveUserProgress(); + return true; } @@ -82,6 +127,11 @@ export class LessonEngine { this.userCode = code; + // Save user code for this lesson + const lessonKey = `${this.currentModule.id}-${this.currentLessonIndex}`; + this.userCodeMap.set(lessonKey, code); + this.saveUserCodeToStorage(); + // Only re-render if code changed or forced update if (forceUpdate || this.lastRenderedCode !== code) { this.lastRenderedCode = code; @@ -180,12 +230,40 @@ export class LessonEngine { const result = validateUserCode(this.userCode, this.currentLesson); - // Display feedback to the user - showFeedback(result.isValid, result.message); + // Mark lesson as completed if valid + if (result.isValid) { + const moduleProgress = this.userProgress[this.currentModule.id]; + if (!moduleProgress.completed.includes(this.currentLessonIndex)) { + moduleProgress.completed.push(this.currentLessonIndex); + this.saveUserProgress(); + } + } return result; } + /** + * Check if current lesson is completed + * @returns {boolean} Whether the lesson is completed + */ + isCurrentLessonCompleted() { + if (!this.currentModule) return false; + + const moduleProgress = this.userProgress[this.currentModule.id]; + return moduleProgress && moduleProgress.completed.includes(this.currentLessonIndex); + } + + /** + * Get completion status for a specific lesson + * @param {string} moduleId - Module ID + * @param {number} lessonIndex - Lesson index + * @returns {boolean} Whether the lesson is completed + */ + isLessonCompleted(moduleId, lessonIndex) { + const moduleProgress = this.userProgress[moduleId]; + return moduleProgress && moduleProgress.completed.includes(lessonIndex); + } + /** * Get the current state of the lesson * @returns {Object} The current lesson state @@ -196,72 +274,123 @@ export class LessonEngine { lesson: this.currentLesson, lessonIndex: this.currentLessonIndex, userCode: this.userCode, - totalLessons: this.currentModule ? this.currentModule.lessons.length : 0 + totalLessons: this.currentModule ? this.currentModule.lessons.length : 0, + isCompleted: this.isCurrentLessonCompleted(), + canGoNext: this.currentLessonIndex < (this.currentModule ? this.currentModule.lessons.length - 1 : 0), + canGoPrev: this.currentLessonIndex > 0 + }; + } + + /** + * Get overall progress statistics + * @returns {Object} Progress statistics + */ + getProgressStats() { + let totalLessons = 0; + let totalCompleted = 0; + + this.modules.forEach((module) => { + totalLessons += module.lessons.length; + const progress = this.userProgress[module.id]; + if (progress && progress.completed) { + totalCompleted += progress.completed.length; + } + }); + + return { + totalLessons, + totalCompleted, + percentComplete: totalLessons > 0 ? Math.round((totalCompleted / totalLessons) * 100) : 0 }; } /** * Save progress to localStorage */ - saveProgress() { - if (!this.currentModule || !this.currentLesson) return; - - const progressData = { - moduleId: this.currentModule.id, - lessonIndex: this.currentLessonIndex, - userCode: this.userCode, - timestamp: new Date().toISOString() - }; - - localStorage.setItem("codeCrispies.progress", JSON.stringify(progressData)); - } - - /** - * Load progress from localStorage - * @param {Array} modules - Available modules - * @returns {Object|null} Loaded progress data or null if not found - */ - loadProgress(modules) { - const savedProgress = localStorage.getItem("codeCrispies.progress"); - if (!savedProgress) return null; - + saveUserProgress() { try { - const progressData = JSON.parse(savedProgress); - - // Find the module - const module = modules.find((m) => m.id === progressData.moduleId); - if (!module) return null; - - this.setModule(module); - this.setLessonByIndex(progressData.lessonIndex); - - // Restore user code if available - if (progressData.userCode) { - this.userCode = progressData.userCode; - this.renderPreview(); - } - - return progressData; + const progressData = { + ...this.userProgress, + lastModuleId: this.currentModule?.id, + timestamp: new Date().toISOString() + }; + localStorage.setItem("codeCrispies.progress", JSON.stringify(progressData)); } catch (e) { - console.error("Error loading progress:", e); - return null; + console.error("Error saving progress:", e); } } /** - * Reset the current state + * Load progress from localStorage + */ + loadUserProgress() { + try { + const savedProgress = localStorage.getItem("codeCrispies.progress"); + if (savedProgress) { + const progressData = JSON.parse(savedProgress); + + // Extract user progress, excluding metadata + const { lastModuleId, timestamp, ...userProgress } = progressData; + this.userProgress = userProgress; + + return { lastModuleId, timestamp }; + } + } catch (e) { + console.error("Error loading progress:", e); + } + return null; + } + + /** + * Save user code to localStorage + */ + saveUserCodeToStorage() { + try { + localStorage.setItem("codeCrispies.userCode", JSON.stringify(Array.from(this.userCodeMap.entries()))); + } catch (e) { + console.error("Error saving user code:", e); + } + } + + /** + * Load user code from localStorage + */ + loadUserCodeFromStorage() { + try { + const savedCode = localStorage.getItem("codeCrispies.userCode"); + if (savedCode) { + const codeEntries = JSON.parse(savedCode); + this.userCodeMap = new Map(codeEntries); + } + } catch (e) { + console.error("Error loading user code:", e); + } + } + + /** + * Reset the current lesson */ reset() { if (this.currentLesson) { this.userCode = this.currentLesson.initialCode || ""; + + // Clear saved user code for this lesson + const lessonKey = `${this.currentModule.id}-${this.currentLessonIndex}`; + this.userCodeMap.delete(lessonKey); + this.saveUserCodeToStorage(); + this.renderPreview(); } } /** - * Clear all saved progress + * Clear all saved progress and user code */ clearProgress() { + this.userProgress = {}; + this.userCodeMap.clear(); localStorage.removeItem("codeCrispies.progress"); + localStorage.removeItem("codeCrispies.userCode"); + localStorage.removeItem("codeCrispies.lastModuleId"); } -} +} \ No newline at end of file