feat(vocab): enhance lesson state management and persistence in VocabLessonView
All checks were successful
Deploy to production / deploy (push) Successful in 3m20s

- Added a new JSONB field `lessonState` to the VocabCourseProgress model to store detailed lesson state information.
- Implemented methods in VocabService for sanitizing and serializing lesson state, ensuring robust data handling.
- Updated VocabLessonView to manage lesson state persistence, including local storage and server synchronization, improving user experience during vocabulary lessons.
- Introduced mechanisms for exporting and normalizing exercise answers, enhancing the accuracy of saved progress.
This commit is contained in:
Torsten Schulz (local)
2026-04-01 11:16:56 +02:00
parent 84adfeafb4
commit a3b820cea0
4 changed files with 505 additions and 12 deletions

View File

@@ -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);