feat(vocab): implement repeat queue management in VocabService and VocabLessonView
All checks were successful
Deploy to production / deploy (push) Successful in 2m59s
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:
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user