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;
|
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) {
|
_sanitizeLessonState(value) {
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
return {};
|
return {};
|
||||||
@@ -136,6 +151,7 @@ export default class VocabService {
|
|||||||
'vocabTrainerCurrentAttempts',
|
'vocabTrainerCurrentAttempts',
|
||||||
'vocabTrainerReviewAttempts',
|
'vocabTrainerReviewAttempts',
|
||||||
'vocabTrainerStats',
|
'vocabTrainerStats',
|
||||||
|
'vocabTrainerRepeatQueue',
|
||||||
'exerciseAnswers',
|
'exerciseAnswers',
|
||||||
'exerciseResults',
|
'exerciseResults',
|
||||||
'exerciseRetryPending',
|
'exerciseRetryPending',
|
||||||
@@ -165,6 +181,7 @@ export default class VocabService {
|
|||||||
vocabTrainerCurrentAttempts: this._clampInteger(value.vocabTrainerCurrentAttempts, { max: 10000 }),
|
vocabTrainerCurrentAttempts: this._clampInteger(value.vocabTrainerCurrentAttempts, { max: 10000 }),
|
||||||
vocabTrainerReviewAttempts: this._clampInteger(value.vocabTrainerReviewAttempts, { max: 10000 }),
|
vocabTrainerReviewAttempts: this._clampInteger(value.vocabTrainerReviewAttempts, { max: 10000 }),
|
||||||
vocabTrainerStats: this._sanitizeVocabTrainerStats(value.vocabTrainerStats),
|
vocabTrainerStats: this._sanitizeVocabTrainerStats(value.vocabTrainerStats),
|
||||||
|
vocabTrainerRepeatQueue: this._sanitizeRepeatQueue(value.vocabTrainerRepeatQueue),
|
||||||
exerciseAnswers: this._sanitizeExerciseAnswers(value.exerciseAnswers),
|
exerciseAnswers: this._sanitizeExerciseAnswers(value.exerciseAnswers),
|
||||||
exerciseResults: this._sanitizeExerciseResults(value.exerciseResults),
|
exerciseResults: this._sanitizeExerciseResults(value.exerciseResults),
|
||||||
exerciseRetryPending: Boolean(value.exerciseRetryPending),
|
exerciseRetryPending: Boolean(value.exerciseRetryPending),
|
||||||
|
|||||||
@@ -221,7 +221,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Multiple Choice Modus -->
|
<!-- 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">
|
<div class="choice-buttons">
|
||||||
<button
|
<button
|
||||||
v-for="(option, index) in vocabTrainerChoiceOptions"
|
v-for="(option, index) in vocabTrainerChoiceOptions"
|
||||||
@@ -235,7 +235,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Texteingabe Modus -->
|
<!-- 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">
|
<div v-if="vocabTrainerAutoSwitchedToTyping" class="mode-switch-notice">
|
||||||
<button @click="switchBackToMultipleChoice" class="btn-switch-mode">
|
<button @click="switchBackToMultipleChoice" class="btn-switch-mode">
|
||||||
{{ $t('socialnetwork.vocab.courses.switchBackToMultipleChoice') }}
|
{{ $t('socialnetwork.vocab.courses.switchBackToMultipleChoice') }}
|
||||||
@@ -253,8 +253,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- "Weiter"-Button nur bei falscher Antwort (bei richtiger Antwort wird automatisch weiter gemacht) -->
|
<!-- "Weiter"-Button nur bei falscher Antwort (bei richtiger Antwort wird automatisch weiter gemacht) -->
|
||||||
<div v-if="vocabTrainerAnswered && !vocabTrainerLastCorrect" class="vocab-next">
|
<div v-if="vocabTrainerMode === 'multiple_choice' && vocabTrainerAnswered && !vocabTrainerLastCorrect" class="vocab-next">
|
||||||
<button @click="nextVocabQuestion">{{ $t('socialnetwork.vocab.courses.next') }}</button>
|
<button @click="continueAfterVocabAnswer">{{ $t('socialnetwork.vocab.courses.next') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -854,6 +854,7 @@ import apiClient from '@/utils/axios.js';
|
|||||||
|
|
||||||
const debugLog = () => {};
|
const debugLog = () => {};
|
||||||
const LESSON_STATE_VERSION = 1;
|
const LESSON_STATE_VERSION = 1;
|
||||||
|
const VOCAB_REPEAT_INTERVALS = [1, 2, 4];
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'VocabLessonView',
|
name: 'VocabLessonView',
|
||||||
@@ -883,6 +884,7 @@ export default {
|
|||||||
vocabTrainerWrong: 0,
|
vocabTrainerWrong: 0,
|
||||||
vocabTrainerTotalAttempts: 0,
|
vocabTrainerTotalAttempts: 0,
|
||||||
vocabTrainerStats: {}, // { [vocabKey]: { attempts: 0, correct: 0, wrong: 0 } }
|
vocabTrainerStats: {}, // { [vocabKey]: { attempts: 0, correct: 0, wrong: 0 } }
|
||||||
|
vocabTrainerRepeatQueue: [],
|
||||||
vocabTrainerChoiceOptions: [],
|
vocabTrainerChoiceOptions: [],
|
||||||
vocabTrainerPhase: 'current', // 'current' = aktuelle Lektion, 'mixed' = gemischt mit alten
|
vocabTrainerPhase: 'current', // 'current' = aktuelle Lektion, 'mixed' = gemischt mit alten
|
||||||
vocabTrainerMixedPool: [], // Pool aus alten Lektionsvokabeln
|
vocabTrainerMixedPool: [], // Pool aus alten Lektionsvokabeln
|
||||||
@@ -1240,6 +1242,7 @@ export default {
|
|||||||
vocabTrainerWrong: this.vocabTrainerWrong,
|
vocabTrainerWrong: this.vocabTrainerWrong,
|
||||||
vocabTrainerTotalAttempts: this.vocabTrainerTotalAttempts,
|
vocabTrainerTotalAttempts: this.vocabTrainerTotalAttempts,
|
||||||
vocabTrainerStats: this.vocabTrainerStats,
|
vocabTrainerStats: this.vocabTrainerStats,
|
||||||
|
vocabTrainerRepeatQueue: this.vocabTrainerRepeatQueue,
|
||||||
vocabTrainerCurrentAttempts: this.vocabTrainerCurrentAttempts,
|
vocabTrainerCurrentAttempts: this.vocabTrainerCurrentAttempts,
|
||||||
vocabTrainerReviewAttempts: this.vocabTrainerReviewAttempts,
|
vocabTrainerReviewAttempts: this.vocabTrainerReviewAttempts,
|
||||||
exerciseRetryPending: this.exerciseRetryPending,
|
exerciseRetryPending: this.exerciseRetryPending,
|
||||||
@@ -1474,6 +1477,9 @@ export default {
|
|||||||
this.exerciseRetryPending = Boolean(parsedState.exerciseRetryPending);
|
this.exerciseRetryPending = Boolean(parsedState.exerciseRetryPending);
|
||||||
this.exerciseRetryPendingSinceAttempts = Math.max(0, Number(parsedState.exerciseRetryPendingSinceAttempts) || 0);
|
this.exerciseRetryPendingSinceAttempts = Math.max(0, Number(parsedState.exerciseRetryPendingSinceAttempts) || 0);
|
||||||
this.vocabTrainerMixedPool = this._buildMixedPool();
|
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.vocabTrainerMixedAttempts = 0;
|
||||||
this.vocabTrainerPhase = this.hasPreviousVocab && this.currentReviewShare > 0 ? 'mixed' : 'current';
|
this.vocabTrainerPhase = this.hasPreviousVocab && this.currentReviewShare > 0 ? 'mixed' : 'current';
|
||||||
this.currentVocabQuestion = null;
|
this.currentVocabQuestion = null;
|
||||||
@@ -1756,6 +1762,7 @@ export default {
|
|||||||
this.vocabTrainerActive = false;
|
this.vocabTrainerActive = false;
|
||||||
this.vocabTrainerPool = [];
|
this.vocabTrainerPool = [];
|
||||||
this.vocabTrainerMixedPool = [];
|
this.vocabTrainerMixedPool = [];
|
||||||
|
this.vocabTrainerRepeatQueue = [];
|
||||||
this.vocabTrainerPhase = 'current';
|
this.vocabTrainerPhase = 'current';
|
||||||
this.vocabTrainerCurrentAttempts = 0;
|
this.vocabTrainerCurrentAttempts = 0;
|
||||||
this.vocabTrainerReviewAttempts = 0;
|
this.vocabTrainerReviewAttempts = 0;
|
||||||
@@ -2331,6 +2338,7 @@ export default {
|
|||||||
this.vocabTrainerCurrentAttempts = 0;
|
this.vocabTrainerCurrentAttempts = 0;
|
||||||
this.vocabTrainerReviewAttempts = 0;
|
this.vocabTrainerReviewAttempts = 0;
|
||||||
this.vocabTrainerStats = {};
|
this.vocabTrainerStats = {};
|
||||||
|
this.vocabTrainerRepeatQueue = [];
|
||||||
// Bereite Mixed-Pool aus alten Vokabeln vor (ohne Duplikate aus aktueller Lektion)
|
// Bereite Mixed-Pool aus alten Vokabeln vor (ohne Duplikate aus aktueller Lektion)
|
||||||
this.vocabTrainerMixedPool = this._buildMixedPool();
|
this.vocabTrainerMixedPool = this._buildMixedPool();
|
||||||
this.vocabTrainerPhase = 'current';
|
this.vocabTrainerPhase = 'current';
|
||||||
@@ -2351,6 +2359,7 @@ export default {
|
|||||||
this.vocabTrainerCurrentAttempts = 0;
|
this.vocabTrainerCurrentAttempts = 0;
|
||||||
this.vocabTrainerReviewAttempts = 0;
|
this.vocabTrainerReviewAttempts = 0;
|
||||||
this.vocabTrainerMixedPool = [];
|
this.vocabTrainerMixedPool = [];
|
||||||
|
this.vocabTrainerRepeatQueue = [];
|
||||||
this.currentVocabQuestion = null;
|
this.currentVocabQuestion = null;
|
||||||
this.vocabTrainerAnswer = '';
|
this.vocabTrainerAnswer = '';
|
||||||
this.vocabTrainerSelectedChoice = null;
|
this.vocabTrainerSelectedChoice = null;
|
||||||
@@ -2375,6 +2384,79 @@ export default {
|
|||||||
}
|
}
|
||||||
return this.vocabTrainerStats[key];
|
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() {
|
checkVocabModeSwitch() {
|
||||||
this.updateExerciseUnlockState();
|
this.updateExerciseUnlockState();
|
||||||
|
|
||||||
@@ -2507,13 +2589,21 @@ export default {
|
|||||||
|
|
||||||
let questionSource = 'current';
|
let questionSource = 'current';
|
||||||
let sourcePool = this.trainableLessonVocab;
|
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;
|
sourcePool = this.vocabTrainerPool;
|
||||||
if (this.vocabTrainerMixedPool.length > 0 && Math.random() < 0.35) {
|
if (this.vocabTrainerMixedPool.length > 0 && Math.random() < 0.35) {
|
||||||
questionSource = 'review';
|
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;
|
sourcePool = this.vocabTrainerMixedPool;
|
||||||
questionSource = 'review';
|
questionSource = 'review';
|
||||||
}
|
}
|
||||||
@@ -2523,6 +2613,14 @@ export default {
|
|||||||
questionSource = 'current';
|
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 randomIndex = Math.floor(Math.random() * sourcePool.length);
|
||||||
const vocab = sourcePool[randomIndex];
|
const vocab = sourcePool[randomIndex];
|
||||||
this.vocabTrainerDirection = Math.random() < 0.5 ? 'L2R' : 'R2L';
|
this.vocabTrainerDirection = Math.random() < 0.5 ? 'L2R' : 'R2L';
|
||||||
@@ -2615,9 +2713,11 @@ export default {
|
|||||||
if (this.vocabTrainerLastCorrect) {
|
if (this.vocabTrainerLastCorrect) {
|
||||||
this.vocabTrainerCorrect++;
|
this.vocabTrainerCorrect++;
|
||||||
stats.correct++;
|
stats.correct++;
|
||||||
|
this.resolveRepeatedVocab(this.currentVocabQuestion.vocab);
|
||||||
} else {
|
} else {
|
||||||
this.vocabTrainerWrong++;
|
this.vocabTrainerWrong++;
|
||||||
stats.wrong++;
|
stats.wrong++;
|
||||||
|
this.queueFailedVocab(this.currentVocabQuestion.vocab);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.vocabTrainerAnswered = true;
|
this.vocabTrainerAnswered = true;
|
||||||
@@ -2630,7 +2730,7 @@ export default {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Prüfe erneut, ob noch Fragen vorhanden sind (könnte sich geändert haben)
|
// Prüfe erneut, ob noch Fragen vorhanden sind (könnte sich geändert haben)
|
||||||
if (this.vocabTrainerPool && this.vocabTrainerPool.length > 0 && this.vocabTrainerActive) {
|
if (this.vocabTrainerPool && this.vocabTrainerPool.length > 0 && this.vocabTrainerActive) {
|
||||||
this.nextVocabQuestion();
|
this.continueAfterVocabAnswer();
|
||||||
}
|
}
|
||||||
}, delay);
|
}, delay);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user