feat(VocabLessonView): refine vocab trainer experience and improve user guidance

- Updated vocab trainer descriptions and button labels to better reflect user progress and encourage engagement.
- Enhanced visual indicators for exercise availability and review priorities, clarifying the learning path for users.
- Introduced new methods to calculate trainer targets and review blending, optimizing the learning experience based on user performance.
This commit is contained in:
Torsten Schulz (local)
2026-03-28 23:41:25 +01:00
parent a2c86247b6
commit c9a7619737

View File

@@ -192,17 +192,17 @@
<div v-if="importantVocab && importantVocab.length > 0" class="vocab-trainer-section">
<h4>{{ $t('socialnetwork.vocab.courses.vocabTrainer') }}</h4>
<div v-if="hasPreviousVocab" class="review-priority-note">
<strong>Tageswiederholung zuerst</strong>
<p>Ältere Vokabeln stehen hier bewusst vor der Kapitel-Prüfung, damit Wiederholung täglich mitläuft.</p>
<strong>Wiederholung läuft schrittweise mit</strong>
<p>Zuerst liegt der Fokus auf den neuen Begriffen dieser Lektion. Mit deinem Fortschritt fließen ältere Vokabeln dann zunehmend mit ein.</p>
</div>
<div v-if="hasExercises && !canAccessExercises" class="exercise-lock-note">
<strong>Kapitel-Prüfung noch gesperrt</strong>
<p>{{ exerciseUnlockHint }}</p>
</div>
<div v-if="!vocabTrainerActive" class="vocab-trainer-start">
<p>{{ hasPreviousVocab ? 'Starte mit der Wiederholung älterer Vokabeln und arbeite dich dann zur aktuellen Lektion vor.' : $t('socialnetwork.vocab.courses.vocabTrainerDescription') }}</p>
<p>{{ hasPreviousVocab ? 'Starte mit den neuen Vokabeln dieser Lektion. Mit fortschreitendem Üben mischt der Trainer automatisch passende Wiederholungen ein.' : $t('socialnetwork.vocab.courses.vocabTrainerDescription') }}</p>
<button @click="startVocabTrainer" class="btn-start-trainer">
{{ hasPreviousVocab ? 'Tageswiederholung starten' : $t('socialnetwork.vocab.courses.startVocabTrainer') }}
{{ hasPreviousVocab ? 'Lektion starten' : $t('socialnetwork.vocab.courses.startVocabTrainer') }}
</button>
</div>
<div v-else class="vocab-trainer-active">
@@ -220,7 +220,7 @@
{{ $t('socialnetwork.vocab.courses.currentLesson') || 'Aktuelle Lektion' }}
</span>
<span v-if="previousVocab && previousVocab.length > 0" class="mode-badge" :class="{ 'mode-active': vocabTrainerPhase === 'mixed' }">
{{ $t('socialnetwork.vocab.courses.mixedReview') || 'Wiederholung' }}
{{ $t('socialnetwork.vocab.courses.mixedReview') || 'Gemischt' }}
</span>
<span class="mode-badge" :class="{ 'mode-active': vocabTrainerMode === 'multiple_choice', 'mode-completed': vocabTrainerMode === 'typing' }">
{{ $t('socialnetwork.vocab.courses.modeMultipleChoice') }}
@@ -230,6 +230,11 @@
</span>
<button @click="stopVocabTrainer" class="btn-stop-trainer">{{ $t('socialnetwork.vocab.courses.stopTrainer') }}</button>
</div>
<div v-if="hasPreviousVocab" class="stats-row trainer-progress-row">
<span>Neue Inhalte: {{ vocabTrainerCurrentAttempts }}/{{ trainerNewFocusTarget }}</span>
<span>Wiederholung: {{ vocabTrainerReviewAttempts }}</span>
<span>Mischanteil: {{ Math.round(currentReviewShare * 100) }}%</span>
</div>
</div>
<div v-if="currentVocabQuestion" class="vocab-question">
<div class="vocab-prompt">
@@ -701,6 +706,8 @@ export default {
vocabTrainerPhase: 'current', // 'current' = aktuelle Lektion, 'mixed' = gemischt mit alten
vocabTrainerMixedPool: [], // Pool aus alten Lektionsvokabeln
vocabTrainerMixedAttempts: 0, // Zähler für Mixed-Phase
vocabTrainerCurrentAttempts: 0,
vocabTrainerReviewAttempts: 0,
exercisePreparationCompleted: false,
currentVocabQuestion: null,
vocabTrainerAnswer: '',
@@ -740,6 +747,42 @@ export default {
hasPreviousVocab() {
return Array.isArray(this.previousVocab) && this.previousVocab.length > 0;
},
lessonComplexityWeight() {
const lessonType = this.lesson?.lessonType;
if (['dialogue', 'phrases', 'survival', 'grammar'].includes(lessonType)) {
return 1.2;
}
if (lessonType === 'review' || lessonType === 'vocab_review') {
return 0.9;
}
return 1;
},
trainerNewFocusTarget() {
const vocabCount = this.importantVocab?.length || 0;
const exerciseCount = this.effectiveExercises?.length || 0;
const durationBonus = Math.max(0, Math.round((this.lesson?.targetMinutes || 0) / 5) - 1);
const baseTarget = Math.ceil((Math.max(vocabCount, 4) * 1.35) + (exerciseCount * 0.35) + durationBonus);
const weightedTarget = Math.ceil(baseTarget * this.lessonComplexityWeight);
return Math.min(24, Math.max(6, weightedTarget));
},
trainerReviewBlendStart() {
return Math.max(3, Math.ceil(this.trainerNewFocusTarget * 0.4));
},
trainerReviewRampWindow() {
return Math.max(4, this.trainerNewFocusTarget - this.trainerReviewBlendStart);
},
trainerExerciseUnlockAttempts() {
const unlockTarget = this.trainerNewFocusTarget + Math.ceil((this.effectiveExercises?.length || 0) * 0.25);
return Math.min(28, Math.max(6, unlockTarget));
},
currentReviewShare() {
if (!this.hasPreviousVocab) {
return 0;
}
const progressPastBlendStart = Math.max(0, this.vocabTrainerCurrentAttempts - this.trainerReviewBlendStart);
const normalizedRamp = Math.min(1, progressPastBlendStart / this.trainerReviewRampWindow);
return Math.min(0.55, normalizedRamp * 0.55);
},
canAccessExercises() {
if (!this.hasExercises) return false;
const isReview = this.lesson?.lessonType === 'review' || this.lesson?.lessonType === 'vocab_review';
@@ -747,9 +790,9 @@ export default {
},
exerciseUnlockHint() {
if (this.hasPreviousVocab) {
return 'Beantworte zuerst einige Wiederholungsfragen aus älteren Lektionen richtig. Danach wird die Kapitel-Prüfung freigeschaltet.';
return `Lerne zuerst die neuen Inhalte der Lektion und arbeite dich durch ungefähr ${this.trainerExerciseUnlockAttempts} Trainerfragen. Ältere Vokabeln werden dabei nach und nach zugemischt.`;
}
return 'Arbeite zuerst kurz mit dem Vokabeltrainer der aktuellen Lektion. Danach wird die Kapitel-Prüfung freigeschaltet.';
return `Arbeite zuerst durch ungefähr ${this.trainerExerciseUnlockAttempts} Trainerfragen aus dieser Lektion. Danach wird die Kapitel-Prüfung freigeschaltet.`;
},
/** Für Wiederholungslektionen: Übungen aus vorherigen Lektionen (Kapitelprüfung). Sonst: eigene Grammatik-Übungen. */
effectiveExercises() {
@@ -878,19 +921,13 @@ export default {
return;
}
const minimumAttempts = this.hasPreviousVocab ? 8 : 6;
const minimumAttempts = this.trainerExerciseUnlockAttempts;
const successRate = this.vocabTrainerTotalAttempts > 0
? (this.vocabTrainerCorrect / this.vocabTrainerTotalAttempts) * 100
: 0;
const currentLessonReady = this.vocabTrainerCurrentAttempts >= this.trainerNewFocusTarget;
if (this.hasPreviousVocab) {
if (this.vocabTrainerPhase === 'mixed' && this.vocabTrainerTotalAttempts >= minimumAttempts && successRate >= 70) {
this.exercisePreparationCompleted = true;
}
return;
}
if (this.vocabTrainerTotalAttempts >= minimumAttempts && successRate >= 70) {
if (currentLessonReady && this.vocabTrainerTotalAttempts >= minimumAttempts && successRate >= 70) {
this.exercisePreparationCompleted = true;
}
},
@@ -1052,6 +1089,8 @@ export default {
this.vocabTrainerPool = [];
this.vocabTrainerMixedPool = [];
this.vocabTrainerPhase = 'current';
this.vocabTrainerCurrentAttempts = 0;
this.vocabTrainerReviewAttempts = 0;
// Reset Flags
this.isCheckingLessonCompletion = false;
this.isNavigatingToNext = false;
@@ -1471,14 +1510,14 @@ export default {
this.vocabTrainerCorrect = 0;
this.vocabTrainerWrong = 0;
this.vocabTrainerTotalAttempts = 0;
this.vocabTrainerCurrentAttempts = 0;
this.vocabTrainerReviewAttempts = 0;
this.vocabTrainerStats = {};
// Bereite Mixed-Pool aus alten Vokabeln vor (ohne Duplikate aus aktueller Lektion)
this.vocabTrainerMixedPool = this._buildMixedPool();
this.vocabTrainerPhase = this.vocabTrainerMixedPool.length > 0 ? 'mixed' : 'current';
this.vocabTrainerPhase = 'current';
this.vocabTrainerMixedAttempts = 0;
this.vocabTrainerPool = this.vocabTrainerPhase === 'mixed'
? [...this.vocabTrainerMixedPool]
: [...this.importantVocab];
this.vocabTrainerPool = [...this.importantVocab];
debugLog('[VocabLessonView] Mixed-Pool:', this.vocabTrainerMixedPool.length, 'Vokabeln');
debugLog('[VocabLessonView] Rufe nextVocabQuestion auf');
this.$nextTick(() => {
@@ -1491,6 +1530,8 @@ export default {
this.vocabTrainerAutoSwitchedToTyping = false;
this.vocabTrainerPhase = 'current';
this.vocabTrainerMixedAttempts = 0;
this.vocabTrainerCurrentAttempts = 0;
this.vocabTrainerReviewAttempts = 0;
this.vocabTrainerMixedPool = [];
this.currentVocabQuestion = null;
this.vocabTrainerAnswer = '';
@@ -1517,44 +1558,16 @@ export default {
return this.vocabTrainerStats[key];
},
checkVocabModeSwitch() {
const MC_THRESHOLD = 60; // Multiple Choice Versuche pro Phase
const MIXED_LIMIT = 40; // Anzahl gemischter Vokabeln aus alten Lektionen
this.updateExerciseUnlockState();
if (this.vocabTrainerPhase === 'current') {
// Phase 1: Aktuelle Lektion - nach MC_THRESHOLD Versuchen mit 80% → Wechsel zu Mixed oder Typing
if (this.vocabTrainerMode === 'multiple_choice' && this.vocabTrainerTotalAttempts >= MC_THRESHOLD) {
this.vocabTrainerPhase = this.hasPreviousVocab && this.currentReviewShare > 0 ? 'mixed' : 'current';
if (this.vocabTrainerMode === 'multiple_choice' && this.vocabTrainerTotalAttempts >= this.trainerExerciseUnlockAttempts) {
const successRate = (this.vocabTrainerCorrect / this.vocabTrainerTotalAttempts) * 100;
if (successRate >= 80) {
// Wechsel zur Mixed-Phase (falls alte Vokabeln vorhanden)
if (this.vocabTrainerMixedPool.length > 0) {
debugLog('[VocabLessonView] Wechsel zu Mixed-Phase mit', this.vocabTrainerMixedPool.length, 'alten Vokabeln');
this.vocabTrainerPhase = 'mixed';
this.vocabTrainerPool = [...this.vocabTrainerMixedPool];
this.vocabTrainerMixedAttempts = 0;
// Stats zurücksetzen für neue Phase
this.vocabTrainerCorrect = 0;
this.vocabTrainerWrong = 0;
this.vocabTrainerTotalAttempts = 0;
} else {
// Kein Mixed-Pool → direkt zu Typing
this.vocabTrainerMode = 'typing';
this.vocabTrainerAutoSwitchedToTyping = true;
this.vocabTrainerPool = [...this.importantVocab];
this.vocabTrainerCorrect = 0;
this.vocabTrainerWrong = 0;
this.vocabTrainerTotalAttempts = 0;
}
}
}
} else if (this.vocabTrainerPhase === 'mixed') {
// Phase 2: Gemischte Wiederholung - nach MIXED_LIMIT Versuchen → Wechsel zu Typing mit allen Vokabeln
if (this.vocabTrainerMode === 'multiple_choice' && this.vocabTrainerTotalAttempts >= MIXED_LIMIT) {
debugLog('[VocabLessonView] Mixed-Phase abgeschlossen, wechsle zu Typing');
this.vocabTrainerMode = 'typing';
this.vocabTrainerAutoSwitchedToTyping = true;
// Im Typing: Pool aus aktuellen + alten Vokabeln kombinieren
this.vocabTrainerPool = [...this.importantVocab, ...this.vocabTrainerMixedPool];
this.vocabTrainerCorrect = 0;
this.vocabTrainerWrong = 0;
@@ -1566,12 +1579,9 @@ export default {
// Wechsle zurück zu Multiple Choice
this.vocabTrainerMode = 'multiple_choice';
this.vocabTrainerAutoSwitchedToTyping = false;
// Zurück zur aktuellen Phase mit passendem Pool
if (this.vocabTrainerPhase === 'mixed') {
this.vocabTrainerPool = [...this.vocabTrainerMixedPool];
} else {
this.vocabTrainerPool = [...this.importantVocab];
}
this.vocabTrainerPool = this.vocabTrainerPhase === 'mixed'
? [...this.importantVocab, ...this.vocabTrainerMixedPool]
: [...this.importantVocab];
// Reset Stats für Multiple Choice Modus
this.vocabTrainerCorrect = 0;
this.vocabTrainerWrong = 0;
@@ -1667,15 +1677,33 @@ export default {
// Prüfe ob Modus-Wechsel nötig ist
this.checkVocabModeSwitch();
// Wähle zufällige Vokabel
const randomIndex = Math.floor(Math.random() * this.vocabTrainerPool.length);
const vocab = this.vocabTrainerPool[randomIndex];
let questionSource = 'current';
let sourcePool = this.importantVocab;
if (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) {
sourcePool = this.vocabTrainerMixedPool;
questionSource = 'review';
}
if (!sourcePool || sourcePool.length === 0) {
sourcePool = this.importantVocab;
questionSource = 'current';
}
const randomIndex = Math.floor(Math.random() * sourcePool.length);
const vocab = sourcePool[randomIndex];
this.vocabTrainerDirection = Math.random() < 0.5 ? 'L2R' : 'R2L';
this.currentVocabQuestion = {
vocab: vocab,
prompt: this.vocabTrainerDirection === 'L2R' ? vocab.learning : vocab.reference,
answer: this.vocabTrainerDirection === 'L2R' ? vocab.reference : vocab.learning,
key: this.getVocabKey(vocab)
key: this.getVocabKey(vocab),
source: questionSource
};
debugLog('[VocabLessonView] Neue Frage erstellt:', this.currentVocabQuestion.prompt);
@@ -1693,7 +1721,7 @@ export default {
// Wichtig: Der Prompt (die Frage) darf nicht in den Optionen erscheinen
this.vocabTrainerChoiceOptions = this.buildChoiceOptions(
this.currentVocabQuestion.answer,
this.vocabTrainerPool,
[...this.importantVocab, ...this.vocabTrainerMixedPool],
this.currentVocabQuestion.prompt // Exkludiere den Prompt
);
debugLog('[VocabLessonView] Choice-Optionen erstellt:', this.vocabTrainerChoiceOptions);
@@ -1736,6 +1764,11 @@ export default {
const stats = this.getVocabStats(this.currentVocabQuestion.vocab);
stats.attempts++;
this.vocabTrainerTotalAttempts++;
if (this.currentVocabQuestion.source === 'review') {
this.vocabTrainerReviewAttempts++;
} else {
this.vocabTrainerCurrentAttempts++;
}
if (this.vocabTrainerLastCorrect) {
this.vocabTrainerCorrect++;
@@ -2445,6 +2478,11 @@ export default {
margin-bottom: 4px;
}
.trainer-progress-row {
font-size: 0.92em;
color: #5b4636;
}
.vocab-trainer-start {
text-align: center;
}