feat(vocab): implement repeat queue management in VocabService and VocabLessonView
All checks were successful
Deploy to production / deploy (push) Successful in 2m59s

- Added a new method in VocabService to sanitize and manage a repeat queue for vocabulary items, enhancing the learning process.
- Updated VocabLessonView to incorporate repeat queue functionality, allowing for better tracking of vocabulary that needs review.
- Refactored existing logic to ensure seamless integration of repeat queue features, improving user experience during vocabulary lessons.
This commit is contained in:
Torsten Schulz (local)
2026-04-01 14:06:34 +02:00
parent a3b820cea0
commit 09015b4244
2 changed files with 124 additions and 7 deletions

View File

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

View File

@@ -221,7 +221,7 @@
</div>
</div>
<!-- Multiple Choice Modus -->
<div v-else-if="vocabTrainerMode === 'multiple_choice'" class="vocab-answer-area multiple-choice">
<div v-if="vocabTrainerMode === 'multiple_choice' && !vocabTrainerAnswered" class="vocab-answer-area multiple-choice">
<div class="choice-buttons">
<button
v-for="(option, index) in vocabTrainerChoiceOptions"
@@ -235,7 +235,7 @@
</div>
</div>
<!-- Texteingabe Modus -->
<div v-else class="vocab-answer-area typing">
<div v-if="vocabTrainerMode === 'typing' && (!vocabTrainerAnswered || !vocabTrainerLastCorrect)" class="vocab-answer-area typing">
<div v-if="vocabTrainerAutoSwitchedToTyping" class="mode-switch-notice">
<button @click="switchBackToMultipleChoice" class="btn-switch-mode">
{{ $t('socialnetwork.vocab.courses.switchBackToMultipleChoice') }}
@@ -253,8 +253,8 @@
</button>
</div>
<!-- "Weiter"-Button nur bei falscher Antwort (bei richtiger Antwort wird automatisch weiter gemacht) -->
<div v-if="vocabTrainerAnswered && !vocabTrainerLastCorrect" class="vocab-next">
<button @click="nextVocabQuestion">{{ $t('socialnetwork.vocab.courses.next') }}</button>
<div v-if="vocabTrainerMode === 'multiple_choice' && vocabTrainerAnswered && !vocabTrainerLastCorrect" class="vocab-next">
<button @click="continueAfterVocabAnswer">{{ $t('socialnetwork.vocab.courses.next') }}</button>
</div>
</div>
</div>
@@ -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);
}