feat(vocab): enhance lesson state management and persistence in VocabLessonView
All checks were successful
Deploy to production / deploy (push) Successful in 3m20s
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:
@@ -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;
|
||||
`);
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ==========
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user