diff --git a/backend/services/vocabService.js b/backend/services/vocabService.js index f5647a2..6ebbe0d 100644 --- a/backend/services/vocabService.js +++ b/backend/services/vocabService.js @@ -115,6 +115,21 @@ export default class VocabService { return sanitized; } + _sanitizeRepeatQueue(value) { + if (!Array.isArray(value)) { + return []; + } + + return value + .slice(0, 100) + .map((entry) => ({ + key: this._sanitizeShortString(entry?.key, 200), + dueAfter: this._clampInteger(entry?.dueAfter, { min: 0, max: 50 }), + stageIndex: this._clampInteger(entry?.stageIndex, { min: 0, max: 10 }) + })) + .filter((entry) => entry.key); + } + _sanitizeLessonState(value) { if (!value || typeof value !== 'object' || Array.isArray(value)) { return {}; @@ -136,6 +151,7 @@ export default class VocabService { 'vocabTrainerCurrentAttempts', 'vocabTrainerReviewAttempts', 'vocabTrainerStats', + 'vocabTrainerRepeatQueue', 'exerciseAnswers', 'exerciseResults', 'exerciseRetryPending', @@ -165,6 +181,7 @@ export default class VocabService { vocabTrainerCurrentAttempts: this._clampInteger(value.vocabTrainerCurrentAttempts, { max: 10000 }), vocabTrainerReviewAttempts: this._clampInteger(value.vocabTrainerReviewAttempts, { max: 10000 }), vocabTrainerStats: this._sanitizeVocabTrainerStats(value.vocabTrainerStats), + vocabTrainerRepeatQueue: this._sanitizeRepeatQueue(value.vocabTrainerRepeatQueue), exerciseAnswers: this._sanitizeExerciseAnswers(value.exerciseAnswers), exerciseResults: this._sanitizeExerciseResults(value.exerciseResults), exerciseRetryPending: Boolean(value.exerciseRetryPending), diff --git a/frontend/src/views/social/VocabLessonView.vue b/frontend/src/views/social/VocabLessonView.vue index ca4c85b..9d512bf 100644 --- a/frontend/src/views/social/VocabLessonView.vue +++ b/frontend/src/views/social/VocabLessonView.vue @@ -221,7 +221,7 @@ -
+
-
+
-
- +
+
@@ -854,6 +854,7 @@ import apiClient from '@/utils/axios.js'; const debugLog = () => {}; const LESSON_STATE_VERSION = 1; +const VOCAB_REPEAT_INTERVALS = [1, 2, 4]; export default { name: 'VocabLessonView', @@ -883,6 +884,7 @@ export default { vocabTrainerWrong: 0, vocabTrainerTotalAttempts: 0, vocabTrainerStats: {}, // { [vocabKey]: { attempts: 0, correct: 0, wrong: 0 } } + vocabTrainerRepeatQueue: [], vocabTrainerChoiceOptions: [], vocabTrainerPhase: 'current', // 'current' = aktuelle Lektion, 'mixed' = gemischt mit alten vocabTrainerMixedPool: [], // Pool aus alten Lektionsvokabeln @@ -1240,6 +1242,7 @@ export default { vocabTrainerWrong: this.vocabTrainerWrong, vocabTrainerTotalAttempts: this.vocabTrainerTotalAttempts, vocabTrainerStats: this.vocabTrainerStats, + vocabTrainerRepeatQueue: this.vocabTrainerRepeatQueue, vocabTrainerCurrentAttempts: this.vocabTrainerCurrentAttempts, vocabTrainerReviewAttempts: this.vocabTrainerReviewAttempts, exerciseRetryPending: this.exerciseRetryPending, @@ -1474,6 +1477,9 @@ export default { this.exerciseRetryPending = Boolean(parsedState.exerciseRetryPending); this.exerciseRetryPendingSinceAttempts = Math.max(0, Number(parsedState.exerciseRetryPendingSinceAttempts) || 0); this.vocabTrainerMixedPool = this._buildMixedPool(); + const knownRepeatKeys = new Set([...this.trainableLessonVocab, ...this.vocabTrainerMixedPool].map((entry) => this.getVocabKey(entry))); + this.vocabTrainerRepeatQueue = this.normalizeRepeatQueue(parsedState.vocabTrainerRepeatQueue) + .filter((entry) => knownRepeatKeys.has(entry.key)); this.vocabTrainerMixedAttempts = 0; this.vocabTrainerPhase = this.hasPreviousVocab && this.currentReviewShare > 0 ? 'mixed' : 'current'; this.currentVocabQuestion = null; @@ -1756,6 +1762,7 @@ export default { this.vocabTrainerActive = false; this.vocabTrainerPool = []; this.vocabTrainerMixedPool = []; + this.vocabTrainerRepeatQueue = []; this.vocabTrainerPhase = 'current'; this.vocabTrainerCurrentAttempts = 0; this.vocabTrainerReviewAttempts = 0; @@ -2331,6 +2338,7 @@ export default { this.vocabTrainerCurrentAttempts = 0; this.vocabTrainerReviewAttempts = 0; this.vocabTrainerStats = {}; + this.vocabTrainerRepeatQueue = []; // Bereite Mixed-Pool aus alten Vokabeln vor (ohne Duplikate aus aktueller Lektion) this.vocabTrainerMixedPool = this._buildMixedPool(); this.vocabTrainerPhase = 'current'; @@ -2351,6 +2359,7 @@ export default { this.vocabTrainerCurrentAttempts = 0; this.vocabTrainerReviewAttempts = 0; this.vocabTrainerMixedPool = []; + this.vocabTrainerRepeatQueue = []; this.currentVocabQuestion = null; this.vocabTrainerAnswer = ''; this.vocabTrainerSelectedChoice = null; @@ -2375,6 +2384,79 @@ export default { } return this.vocabTrainerStats[key]; }, + normalizeRepeatQueue(queue = []) { + if (!Array.isArray(queue)) { + return []; + } + return queue + .map((entry) => ({ + key: String(entry?.key || '').trim(), + dueAfter: Math.max(0, Number(entry?.dueAfter) || 0), + stageIndex: Math.max(0, Math.min(VOCAB_REPEAT_INTERVALS.length - 1, Number(entry?.stageIndex) || 0)) + })) + .filter((entry) => entry.key); + }, + queueFailedVocab(vocab) { + const key = this.getVocabKey(vocab); + const existing = this.vocabTrainerRepeatQueue.find((entry) => entry.key === key); + if (existing) { + existing.dueAfter = VOCAB_REPEAT_INTERVALS[0]; + existing.stageIndex = 0; + return; + } + this.vocabTrainerRepeatQueue.push({ + key, + dueAfter: VOCAB_REPEAT_INTERVALS[0], + stageIndex: 0 + }); + }, + resolveRepeatedVocab(vocab) { + const key = this.getVocabKey(vocab); + const entryIndex = this.vocabTrainerRepeatQueue.findIndex((entry) => entry.key === key && entry.dueAfter <= 0); + if (entryIndex === -1) { + return; + } + const entry = this.vocabTrainerRepeatQueue[entryIndex]; + if (entry.stageIndex >= VOCAB_REPEAT_INTERVALS.length - 1) { + this.vocabTrainerRepeatQueue.splice(entryIndex, 1); + return; + } + entry.stageIndex += 1; + entry.dueAfter = VOCAB_REPEAT_INTERVALS[entry.stageIndex]; + }, + advanceRepeatQueue(completedKey = '') { + this.vocabTrainerRepeatQueue = this.vocabTrainerRepeatQueue + .map((entry) => { + if (entry.key === completedKey) { + return entry; + } + return { + ...entry, + dueAfter: Math.max(0, entry.dueAfter - 1) + }; + }) + .filter((entry) => entry.key); + }, + getRepeatDueVocab() { + const dueEntry = this.vocabTrainerRepeatQueue.find((entry) => entry.dueAfter <= 0); + if (!dueEntry) { + return null; + } + const allVocabs = [...this.trainableLessonVocab, ...this.vocabTrainerMixedPool]; + return allVocabs.find((vocab) => this.getVocabKey(vocab) === dueEntry.key) || null; + }, + getPendingRepeatKeys() { + return new Set( + this.vocabTrainerRepeatQueue + .filter((entry) => entry.dueAfter > 0) + .map((entry) => entry.key) + ); + }, + continueAfterVocabAnswer() { + const completedKey = this.currentVocabQuestion?.key || ''; + this.advanceRepeatQueue(completedKey); + this.nextVocabQuestion(); + }, checkVocabModeSwitch() { this.updateExerciseUnlockState(); @@ -2507,13 +2589,21 @@ export default { let questionSource = 'current'; let sourcePool = this.trainableLessonVocab; + const dueRepeatVocab = this.getRepeatDueVocab(); - if (this.vocabTrainerMode === 'typing') { + if (dueRepeatVocab) { + sourcePool = [dueRepeatVocab]; + questionSource = this.vocabTrainerMixedPool.some((entry) => this.getVocabKey(entry) === this.getVocabKey(dueRepeatVocab)) + ? 'review' + : 'current'; + } + + if (!dueRepeatVocab && this.vocabTrainerMode === 'typing') { sourcePool = this.vocabTrainerPool; if (this.vocabTrainerMixedPool.length > 0 && Math.random() < 0.35) { questionSource = 'review'; } - } else if (this.vocabTrainerMixedPool.length > 0 && this.currentReviewShare > 0 && Math.random() < this.currentReviewShare) { + } else if (!dueRepeatVocab && this.vocabTrainerMixedPool.length > 0 && this.currentReviewShare > 0 && Math.random() < this.currentReviewShare) { sourcePool = this.vocabTrainerMixedPool; questionSource = 'review'; } @@ -2523,6 +2613,14 @@ export default { questionSource = 'current'; } + if (!dueRepeatVocab) { + const pendingRepeatKeys = this.getPendingRepeatKeys(); + const filteredPool = sourcePool.filter((vocab) => !pendingRepeatKeys.has(this.getVocabKey(vocab))); + if (filteredPool.length > 0) { + sourcePool = filteredPool; + } + } + const randomIndex = Math.floor(Math.random() * sourcePool.length); const vocab = sourcePool[randomIndex]; this.vocabTrainerDirection = Math.random() < 0.5 ? 'L2R' : 'R2L'; @@ -2615,9 +2713,11 @@ export default { if (this.vocabTrainerLastCorrect) { this.vocabTrainerCorrect++; stats.correct++; + this.resolveRepeatedVocab(this.currentVocabQuestion.vocab); } else { this.vocabTrainerWrong++; stats.wrong++; + this.queueFailedVocab(this.currentVocabQuestion.vocab); } this.vocabTrainerAnswered = true; @@ -2630,7 +2730,7 @@ export default { setTimeout(() => { // Prüfe erneut, ob noch Fragen vorhanden sind (könnte sich geändert haben) if (this.vocabTrainerPool && this.vocabTrainerPool.length > 0 && this.vocabTrainerActive) { - this.nextVocabQuestion(); + this.continueAfterVocabAnswer(); } }, delay); }