diff --git a/backend/migrations/20260401000000-add-vocab-course-progress-lesson-state.cjs b/backend/migrations/20260401000000-add-vocab-course-progress-lesson-state.cjs new file mode 100644 index 0000000..95e4d4e --- /dev/null +++ b/backend/migrations/20260401000000-add-vocab-course-progress-lesson-state.cjs @@ -0,0 +1,20 @@ +'use strict'; + +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + ALTER TABLE community.vocab_course_progress + ADD COLUMN IF NOT EXISTS lesson_state JSONB NOT NULL DEFAULT '{}'::jsonb; + + COMMENT ON COLUMN community.vocab_course_progress.lesson_state IS + 'Persistierter UI- und Lernzustand pro Nutzer und Lektion fuer Resume im Sprachkurs.'; + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + ALTER TABLE community.vocab_course_progress + DROP COLUMN IF EXISTS lesson_state; + `); + } +}; diff --git a/backend/models/community/vocab_course_progress.js b/backend/models/community/vocab_course_progress.js index 9581f99..21fad46 100644 --- a/backend/models/community/vocab_course_progress.js +++ b/backend/models/community/vocab_course_progress.js @@ -34,6 +34,12 @@ VocabCourseProgress.init({ allowNull: false, defaultValue: 0 }, + lessonState: { + type: DataTypes.JSONB, + allowNull: false, + defaultValue: {}, + field: 'lesson_state' + }, lastAccessedAt: { type: DataTypes.DATE, allowNull: true, diff --git a/backend/services/vocabService.js b/backend/services/vocabService.js index d0db972..f5647a2 100644 --- a/backend/services/vocabService.js +++ b/backend/services/vocabService.js @@ -15,6 +15,181 @@ import { Op } from 'sequelize'; import { BISAYA_PHASE1_DIDACTICS } from '../scripts/bisaya-course-phase1.js'; export default class VocabService { + _clampInteger(value, { min = 0, max = 100000, fallback = 0 } = {}) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return fallback; + } + return Math.max(min, Math.min(max, Math.trunc(numeric))); + } + + _sanitizeShortString(value, maxLength = 400) { + const text = String(value ?? '').trim(); + if (!text) { + return ''; + } + return text.slice(0, maxLength); + } + + _sanitizeStringArray(value, { maxItems = 12, maxLength = 400, keepEmpty = false } = {}) { + if (!Array.isArray(value)) { + return []; + } + return value + .slice(0, maxItems) + .map((entry) => this._sanitizeShortString(entry, maxLength)) + .filter((entry) => keepEmpty || Boolean(entry)); + } + + _sanitizeExerciseAnswers(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + const sanitized = {}; + Object.entries(value).slice(0, 200).forEach(([exerciseId, answer]) => { + if (!/^\d+$/.test(String(exerciseId))) { + return; + } + if (Array.isArray(answer)) { + sanitized[exerciseId] = this._sanitizeStringArray(answer, { + maxItems: 12, + maxLength: 200, + keepEmpty: true + }); + return; + } + if (typeof answer === 'string') { + sanitized[exerciseId] = this._sanitizeShortString(answer, 200); + return; + } + if (typeof answer === 'number' && Number.isFinite(answer)) { + sanitized[exerciseId] = Math.trunc(answer); + } + }); + + return sanitized; + } + + _sanitizeExerciseResults(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + const sanitized = {}; + Object.entries(value).slice(0, 200).forEach(([exerciseId, result]) => { + if (!/^\d+$/.test(String(exerciseId)) || !result || typeof result !== 'object' || Array.isArray(result)) { + return; + } + + sanitized[exerciseId] = { + correct: Boolean(result.correct), + correctAnswer: this._sanitizeShortString(result.correctAnswer, 400), + alternatives: this._sanitizeStringArray(result.alternatives, { maxItems: 8, maxLength: 200 }), + explanation: this._sanitizeShortString(result.explanation, 1200) + }; + }); + + return sanitized; + } + + _sanitizeVocabTrainerStats(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + const sanitized = {}; + Object.entries(value).slice(0, 400).forEach(([key, stats]) => { + const safeKey = this._sanitizeShortString(key, 200); + if (!safeKey || !stats || typeof stats !== 'object' || Array.isArray(stats)) { + return; + } + + sanitized[safeKey] = { + attempts: this._clampInteger(stats.attempts, { max: 5000 }), + correct: this._clampInteger(stats.correct, { max: 5000 }), + wrong: this._clampInteger(stats.wrong, { max: 5000 }) + }; + }); + + return sanitized; + } + + _sanitizeLessonState(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + const knownKeys = [ + 'version', + 'updatedAt', + 'activeTab', + 'exercisePreparationCompleted', + 'lessonPrepStage', + 'lessonPrepIndex', + 'vocabTrainerActive', + 'vocabTrainerMode', + 'vocabTrainerAutoSwitchedToTyping', + 'vocabTrainerCorrect', + 'vocabTrainerWrong', + 'vocabTrainerTotalAttempts', + 'vocabTrainerCurrentAttempts', + 'vocabTrainerReviewAttempts', + 'vocabTrainerStats', + 'exerciseAnswers', + 'exerciseResults', + 'exerciseRetryPending', + 'exerciseRetryPendingSinceAttempts' + ]; + const hasKnownState = knownKeys.some((key) => Object.prototype.hasOwnProperty.call(value, key)); + if (!hasKnownState) { + return {}; + } + + const activeTab = value.activeTab === 'exercises' ? 'exercises' : 'learn'; + const vocabTrainerMode = value.vocabTrainerMode === 'typing' ? 'typing' : 'multiple_choice'; + + return { + version: this._clampInteger(value.version, { min: 1, max: 1000, fallback: 1 }), + updatedAt: this._sanitizeShortString(value.updatedAt || new Date().toISOString(), 64), + activeTab, + exercisePreparationCompleted: Boolean(value.exercisePreparationCompleted), + lessonPrepStage: this._clampInteger(value.lessonPrepStage, { min: 0, max: 2 }), + lessonPrepIndex: this._clampInteger(value.lessonPrepIndex, { min: 0, max: 500 }), + vocabTrainerActive: Boolean(value.vocabTrainerActive), + vocabTrainerMode, + vocabTrainerAutoSwitchedToTyping: Boolean(value.vocabTrainerAutoSwitchedToTyping), + vocabTrainerCorrect: this._clampInteger(value.vocabTrainerCorrect, { max: 5000 }), + vocabTrainerWrong: this._clampInteger(value.vocabTrainerWrong, { max: 5000 }), + vocabTrainerTotalAttempts: this._clampInteger(value.vocabTrainerTotalAttempts, { max: 10000 }), + vocabTrainerCurrentAttempts: this._clampInteger(value.vocabTrainerCurrentAttempts, { max: 10000 }), + vocabTrainerReviewAttempts: this._clampInteger(value.vocabTrainerReviewAttempts, { max: 10000 }), + vocabTrainerStats: this._sanitizeVocabTrainerStats(value.vocabTrainerStats), + exerciseAnswers: this._sanitizeExerciseAnswers(value.exerciseAnswers), + exerciseResults: this._sanitizeExerciseResults(value.exerciseResults), + exerciseRetryPending: Boolean(value.exerciseRetryPending), + exerciseRetryPendingSinceAttempts: this._clampInteger(value.exerciseRetryPendingSinceAttempts, { max: 10000 }) + }; + } + + _serializeLessonProgress(progress, lessonData = null) { + if (!progress) { + return null; + } + + const plainProgress = progress.get ? progress.get({ plain: true }) : { ...progress }; + const targetScore = lessonData?.targetScorePercent || plainProgress.lesson?.targetScorePercent || 80; + const hasReachedTarget = (plainProgress.score || 0) >= targetScore; + + return { + ...plainProgress, + lessonState: this._sanitizeLessonState(plainProgress.lessonState), + targetScore, + hasReachedTarget, + needsReview: Boolean((lessonData?.requiresReview ?? plainProgress.lesson?.requiresReview) && !hasReachedTarget) + }; + } + async _getUserByHashedId(hashedUserId) { const user = await User.findOne({ where: { hashedId: hashedUserId } }); if (!user) { @@ -1652,6 +1827,13 @@ export default class VocabService { throw err; } + const progress = await VocabCourseProgress.findOne({ + where: { + userId: user.id, + lessonId: lesson.id + } + }); + const plainLesson = lesson.get({ plain: true }); // Lade Vokabeln aus vorherigen Lektionen (für Wiederholung UND für gemischten Vokabeltrainer) @@ -1666,6 +1848,7 @@ export default class VocabService { plainLesson.didactics = this._buildLessonDidactics(plainLesson); plainLesson.pedagogy = this._buildLessonPedagogy(plainLesson); + plainLesson.progress = this._serializeLessonProgress(progress, plainLesson); return plainLesson; } @@ -2053,10 +2236,10 @@ export default class VocabService { order: [[{ model: VocabCourseLesson, as: 'lesson' }, 'lessonNumber', 'ASC']] }); - return progress.map(p => p.get({ plain: true })); + return progress.map((entry) => this._serializeLessonProgress(entry, entry.lesson)); } - async updateLessonProgress(hashedUserId, lessonId, { completed, score, timeSpentMinutes }) { + async updateLessonProgress(hashedUserId, lessonId, { completed, score, timeSpentMinutes, lessonState }) { const user = await this._getUserByHashedId(hashedUserId); const lesson = await VocabCourseLesson.findByPk(lessonId, { include: [{ model: VocabCourse, as: 'course' }] @@ -2083,6 +2266,7 @@ export default class VocabService { const targetScore = lessonData.targetScorePercent || 80; const actualScore = Number(score) || 0; const hasReachedTarget = actualScore >= targetScore; + const sanitizedLessonState = lessonState === undefined ? undefined : this._sanitizeLessonState(lessonState); // Prüfe, ob Lektion als abgeschlossen gilt (nur wenn Ziel erreicht oder explizit completed=true) const isCompleted = Boolean(completed) || (hasReachedTarget && lessonData.requiresReview === false); @@ -2095,6 +2279,7 @@ export default class VocabService { lessonId: lesson.id, completed: isCompleted, score: actualScore, + lessonState: sanitizedLessonState || {}, lastAccessedAt: new Date() } }); @@ -2117,6 +2302,9 @@ export default class VocabService { updates.completedAt = new Date(); } } + if (sanitizedLessonState !== undefined) { + updates.lessonState = sanitizedLessonState; + } await progress.update(updates); } else if (isCompleted) { progress.completed = true; @@ -2124,12 +2312,7 @@ export default class VocabService { await progress.save(); } - const progressData = progress.get({ plain: true }); - progressData.targetScore = targetScore; - progressData.hasReachedTarget = progressData.score >= targetScore; - progressData.needsReview = lessonData.requiresReview && !progressData.hasReachedTarget; - - return progressData; + return this._serializeLessonProgress(progress, lessonData); } // ========== GRAMMAR EXERCISE METHODS ========== diff --git a/frontend/src/views/social/VocabLessonView.vue b/frontend/src/views/social/VocabLessonView.vue index 997cee9..ca4c85b 100644 --- a/frontend/src/views/social/VocabLessonView.vue +++ b/frontend/src/views/social/VocabLessonView.vue @@ -853,6 +853,7 @@ import { mapGetters } from 'vuex'; import apiClient from '@/utils/axios.js'; const debugLog = () => {}; +const LESSON_STATE_VERSION = 1; export default { name: 'VocabLessonView', @@ -924,7 +925,11 @@ export default { /** Aus vorherigen Lektionen (MC-Optionen nach Fragentyp Ziel-/Muttersprache) */ distractorPool: { target: [], native: [] }, /** { [exerciseId]: { options: string[], useTextAnswer: boolean } } */ - mcRandomizedOptions: {} + mcRandomizedOptions: {}, + lessonStatePersistenceReady: false, + lessonStateSaveTimer: null, + lessonStateSaveInFlight: false, + pendingLessonStatePayload: null }; }, computed: { @@ -1219,19 +1224,48 @@ export default { { value: 'explain', label: this.$t('socialnetwork.vocab.courses.languageAssistantModeExplain') }, { value: 'correct', label: this.$t('socialnetwork.vocab.courses.languageAssistantModeCorrect') } ]; + }, + persistedLessonStateSnapshot() { + return { + activeTab: this.activeTab, + exerciseAnswers: this.exportPersistedExerciseAnswers(), + exerciseResults: this.exerciseResults, + exercisePreparationCompleted: this.exercisePreparationCompleted, + lessonPrepStage: this.lessonPrepStage, + lessonPrepIndex: this.lessonPrepIndex, + vocabTrainerActive: this.vocabTrainerActive, + vocabTrainerMode: this.vocabTrainerMode, + vocabTrainerAutoSwitchedToTyping: this.vocabTrainerAutoSwitchedToTyping, + vocabTrainerCorrect: this.vocabTrainerCorrect, + vocabTrainerWrong: this.vocabTrainerWrong, + vocabTrainerTotalAttempts: this.vocabTrainerTotalAttempts, + vocabTrainerStats: this.vocabTrainerStats, + vocabTrainerCurrentAttempts: this.vocabTrainerCurrentAttempts, + vocabTrainerReviewAttempts: this.vocabTrainerReviewAttempts, + exerciseRetryPending: this.exerciseRetryPending, + exerciseRetryPendingSinceAttempts: this.exerciseRetryPendingSinceAttempts + }; } }, watch: { - courseId(newVal, oldVal) { + persistedLessonStateSnapshot: { + handler() { + this.persistLessonState(); + }, + deep: true + }, + async courseId(newVal, oldVal) { if (newVal !== oldVal) { + await this.persistLessonState({ immediate: true, lessonIdOverride: this.lesson?.id }); // Reset Flags beim Kurswechsel this.isCheckingLessonCompletion = false; this.isNavigatingToNext = false; this.loadLesson(); } }, - lessonId(newVal, oldVal) { + async lessonId(newVal, oldVal) { if (newVal !== oldVal) { + await this.persistLessonState({ immediate: true, lessonIdOverride: oldVal }); // Reset Flags beim Lektionswechsel this.isCheckingLessonCompletion = false; this.isNavigatingToNext = false; @@ -1240,6 +1274,240 @@ export default { } }, methods: { + exportPersistedExerciseAnswers() { + const exportedAnswers = {}; + this.effectiveExercises.forEach((exercise) => { + const currentAnswer = this.exerciseAnswers[exercise.id]; + if (currentAnswer === undefined || currentAnswer === null || currentAnswer === '') { + return; + } + + if (this.getExerciseType(exercise) === 'multiple_choice') { + if (typeof currentAnswer === 'string' && Number.isNaN(Number(currentAnswer))) { + exportedAnswers[exercise.id] = currentAnswer; + return; + } + const optionIndex = Number(currentAnswer); + const selectedOption = this.getOptions(exercise)[optionIndex]; + if (selectedOption !== undefined) { + exportedAnswers[exercise.id] = String(selectedOption); + } + return; + } + + if (Array.isArray(currentAnswer)) { + exportedAnswers[exercise.id] = currentAnswer.map((entry) => String(entry ?? '')); + return; + } + + exportedAnswers[exercise.id] = String(currentAnswer); + }); + return exportedAnswers; + }, + getLessonStateStorageKey() { + if (typeof window === 'undefined' || !window.localStorage) { + return ''; + } + const userId = this.user?.id || 'guest'; + return `vocab-lesson-state:${LESSON_STATE_VERSION}:${userId}:${this.courseId}:${this.lessonId}`; + }, + buildPersistedLessonState() { + return { + version: LESSON_STATE_VERSION, + updatedAt: new Date().toISOString(), + ...this.persistedLessonStateSnapshot + }; + }, + readLocalLessonState() { + const storageKey = this.getLessonStateStorageKey(); + if (!storageKey) { + return null; + } + try { + const raw = window.localStorage.getItem(storageKey); + return raw ? JSON.parse(raw) : null; + } catch (error) { + console.warn('[VocabLessonView] Konnte gespeicherten Lektionszustand nicht lesen:', error); + return null; + } + }, + async flushLessonStateToServer({ lessonIdOverride = null, payloadOverride = null } = {}) { + if (this.lessonStateSaveInFlight) { + return; + } + + const payload = payloadOverride || this.pendingLessonStatePayload; + const lessonId = lessonIdOverride || this.lessonId; + if (!this.lessonStatePersistenceReady || !payload || !lessonId) { + return; + } + if (!payloadOverride) { + this.pendingLessonStatePayload = null; + } + this.lessonStateSaveInFlight = true; + + try { + const { data } = await apiClient.put(`/api/vocab/lessons/${lessonId}/progress`, { + lessonState: payload + }); + if (!lessonIdOverride && this.lesson) { + this.lesson.progress = data; + } + } catch (error) { + console.warn('[VocabLessonView] Konnte Lektionszustand nicht serverseitig speichern:', error); + if (!payloadOverride) { + this.pendingLessonStatePayload = payload; + } + } finally { + this.lessonStateSaveInFlight = false; + if (this.pendingLessonStatePayload) { + this.lessonStateSaveTimer = window.setTimeout(() => { + this.flushLessonStateToServer(); + }, 800); + } + } + }, + async persistLessonState({ immediate = false, lessonIdOverride = null } = {}) { + if (!this.lessonStatePersistenceReady) { + return; + } + const payload = this.buildPersistedLessonState(); + const shouldWriteLocalCache = !lessonIdOverride || String(lessonIdOverride) === String(this.lessonId); + const storageKey = shouldWriteLocalCache ? this.getLessonStateStorageKey() : ''; + if (storageKey) { + try { + window.localStorage.setItem(storageKey, JSON.stringify(payload)); + } catch (error) { + console.warn('[VocabLessonView] Konnte Lektionszustand nicht lokal speichern:', error); + } + } + this.pendingLessonStatePayload = payload; + if (this.lessonStateSaveTimer) { + window.clearTimeout(this.lessonStateSaveTimer); + this.lessonStateSaveTimer = null; + } + if (immediate) { + await this.flushLessonStateToServer({ lessonIdOverride, payloadOverride: payload }); + return; + } + this.lessonStateSaveTimer = window.setTimeout(() => { + this.flushLessonStateToServer(); + }, 450); + }, + normalizePersistedExerciseAnswers(savedAnswers) { + const normalizedAnswers = { ...this.exerciseAnswers }; + if (!savedAnswers || typeof savedAnswers !== 'object') { + return normalizedAnswers; + } + this.effectiveExercises.forEach((exercise) => { + const saved = savedAnswers[exercise.id]; + if (saved === undefined) { + return; + } + if (this.getExerciseType(exercise) === 'multiple_choice') { + const options = this.getOptions(exercise); + if (typeof saved === 'number' && Number.isFinite(saved) && saved >= 0 && saved < options.length) { + normalizedAnswers[exercise.id] = Math.trunc(saved); + return; + } + const savedText = String(saved ?? '').trim(); + const restoredIndex = options.findIndex((option) => String(option).trim() === savedText); + normalizedAnswers[exercise.id] = restoredIndex >= 0 ? restoredIndex : ''; + return; + } + if (this.getExerciseType(exercise) === 'gap_fill') { + const gapCount = this.getGapCount(exercise); + const values = Array.isArray(saved) ? saved.slice(0, gapCount) : []; + while (values.length < gapCount) { + values.push(''); + } + normalizedAnswers[exercise.id] = values; + return; + } + if (typeof saved === 'string' || typeof saved === 'number') { + normalizedAnswers[exercise.id] = saved; + } + }); + return normalizedAnswers; + }, + restoreLessonState() { + const serverState = this.lesson?.progress?.lessonState; + const localFallbackState = this.readLocalLessonState(); + const parsedState = serverState + && typeof serverState === 'object' + && !Array.isArray(serverState) + && serverState.version === LESSON_STATE_VERSION + ? serverState + : localFallbackState; + + if (!parsedState || parsedState.version !== LESSON_STATE_VERSION) { + this.lessonStatePersistenceReady = true; + return; + } + + this.exerciseAnswers = this.normalizePersistedExerciseAnswers(parsedState.exerciseAnswers); + + const restoredResults = {}; + this.effectiveExercises.forEach((exercise) => { + restoredResults[exercise.id] = Object.prototype.hasOwnProperty.call(parsedState.exerciseResults || {}, exercise.id) + ? parsedState.exerciseResults[exercise.id] + : null; + }); + this.exerciseResults = restoredResults; + + this.exercisePreparationCompleted = Boolean(parsedState.exercisePreparationCompleted); + this.lessonPrepStage = Math.min(2, Math.max(0, Number(parsedState.lessonPrepStage) || 0)); + this.lessonPrepIndex = Math.max(0, Math.min(this.prepItems.length - 1, Number(parsedState.lessonPrepIndex) || 0)); + this.activeTab = parsedState.activeTab === 'exercises' ? 'exercises' : 'learn'; + + this.vocabTrainerActive = Boolean(parsedState.vocabTrainerActive); + this.vocabTrainerMode = parsedState.vocabTrainerMode === 'typing' ? 'typing' : 'multiple_choice'; + this.vocabTrainerAutoSwitchedToTyping = Boolean(parsedState.vocabTrainerAutoSwitchedToTyping); + this.vocabTrainerCorrect = Math.max(0, Number(parsedState.vocabTrainerCorrect) || 0); + this.vocabTrainerWrong = Math.max(0, Number(parsedState.vocabTrainerWrong) || 0); + this.vocabTrainerTotalAttempts = Math.max(0, Number(parsedState.vocabTrainerTotalAttempts) || 0); + this.vocabTrainerStats = parsedState.vocabTrainerStats && typeof parsedState.vocabTrainerStats === 'object' + ? parsedState.vocabTrainerStats + : {}; + this.vocabTrainerCurrentAttempts = Math.max(0, Number(parsedState.vocabTrainerCurrentAttempts) || 0); + this.vocabTrainerReviewAttempts = Math.max(0, Number(parsedState.vocabTrainerReviewAttempts) || 0); + this.exerciseRetryPending = Boolean(parsedState.exerciseRetryPending); + this.exerciseRetryPendingSinceAttempts = Math.max(0, Number(parsedState.exerciseRetryPendingSinceAttempts) || 0); + this.vocabTrainerMixedPool = this._buildMixedPool(); + this.vocabTrainerMixedAttempts = 0; + this.vocabTrainerPhase = this.hasPreviousVocab && this.currentReviewShare > 0 ? 'mixed' : 'current'; + this.currentVocabQuestion = null; + this.vocabTrainerChoiceOptions = []; + this.vocabTrainerAnswer = ''; + this.vocabTrainerSelectedChoice = null; + this.vocabTrainerAnswered = false; + this.vocabTrainerLastCorrect = false; + this.vocabTrainerDirection = 'L2R'; + + this.vocabTrainerPool = this.vocabTrainerMode === 'typing' + ? [...this.trainableLessonVocab, ...this.vocabTrainerMixedPool] + : [...this.trainableLessonVocab]; + + this.updateExerciseUnlockState(); + + if (this.$route.query.assistant || this.$route.query.tab === 'learn') { + this.activeTab = 'learn'; + } else if (this.$route.query.tab === 'exercises' && this.canAccessExercises) { + this.activeTab = 'exercises'; + } else if (this.activeTab === 'exercises' && !this.canAccessExercises) { + this.activeTab = 'learn'; + } + + this.lessonStatePersistenceReady = true; + if (this.vocabTrainerActive && this.vocabTrainerPool.length > 0) { + this.$nextTick(() => { + if (this.vocabTrainerActive && !this.currentVocabQuestion) { + this.nextVocabQuestion(); + } + }); + } + this.persistLessonState(); + }, normalizeLessonVocabTerm(value) { return String(value || '') .trim() @@ -1468,6 +1736,12 @@ export default { debugLog('[VocabLessonView] loadLesson gestartet für lessonId:', this.lessonId); this.loading = true; + this.lessonStatePersistenceReady = false; + if (this.lessonStateSaveTimer) { + window.clearTimeout(this.lessonStateSaveTimer); + this.lessonStateSaveTimer = null; + } + this.pendingLessonStatePayload = null; // Setze Antworten und Ergebnisse zurück this.exerciseAnswers = {}; this.exerciseResults = {}; @@ -1533,9 +1807,11 @@ export default { this.initializeExercises(exercises); this.buildMcRandomizedOptions(); } + this.restoreLessonState(); debugLog('[VocabLessonView] loadLesson abgeschlossen'); } catch (e) { console.error('[VocabLessonView] Fehler beim Laden der Lektion:', e); + this.lessonStatePersistenceReady = true; } finally { this.loading = false; } @@ -1940,11 +2216,14 @@ export default { debugLog('[VocabLessonView] Score berechnet:', score, '%'); // Aktualisiere Fortschritt - await apiClient.put(`/api/vocab/lessons/${this.lessonId}/progress`, { + const lessonState = this.buildPersistedLessonState(); + const { data } = await apiClient.put(`/api/vocab/lessons/${this.lessonId}/progress`, { completed: score >= this.exerciseTargetScore, score: score, + lessonState, timeSpentMinutes: 0 // TODO: Zeit tracken }); + this.lesson.progress = data; debugLog('[VocabLessonView] Fortschritt aktualisiert - starte Navigation'); @@ -2481,6 +2760,11 @@ export default { ]); }, beforeUnmount() { + this.persistLessonState({ immediate: true, lessonIdOverride: this.lesson?.id || this.lessonId }); + if (this.lessonStateSaveTimer) { + window.clearTimeout(this.lessonStateSaveTimer); + this.lessonStateSaveTimer = null; + } // Stoppe alle aktiven Recognition-Instanzen Object.keys(this.activeRecognition).forEach(exerciseId => { this.stopRecognition(exerciseId);