feat(DayProduction, FalukantService, VocabLessonView): enhance vocabulary training and production tracking
All checks were successful
Deploy to production / deploy (push) Successful in 1m51s

- Added a new `completionCount` field to the DayProduction model to track the number of completed productions.
- Updated the FalukantService to aggregate completed productions using the new `completionCount` field, improving accuracy in production statistics.
- Introduced new vocabulary training features in VocabLessonView, including options to mark vocabulary as difficult and track remaining hard vocabulary, enhancing user engagement and learning effectiveness.
- Updated localization files for German and English to support new vocabulary training features, ensuring a consistent user experience across languages.
This commit is contained in:
Torsten Schulz (local)
2026-04-21 15:44:44 +02:00
parent 27d42c0a3a
commit 4cc2aace6b
5 changed files with 275 additions and 3 deletions

View File

@@ -825,6 +825,13 @@
"trainerProgressNewContent": "Neue Inhalte: {current}/{target}",
"trainerProgressReview": "Wiederholung: {count}",
"trainerProgressMixShare": "Mischanteil: {percent}%",
"markVocabHard": "Als schwer markieren",
"markVocabHardSaved": "Vokabel als schwer markiert.",
"unmarkVocabHard": "Aus Schwerliste entfernen",
"unmarkVocabHardSaved": "Vokabel aus der Schwerliste entfernt.",
"hardVocabModeActive": "Intensivblock: schwere Vokabeln",
"hardVocabRemaining": "Offen bis gefestigt: {count}",
"startHardVocabTrainer": "Schwere Vokabeln trainieren ({count})",
"unknownExerciseTypeNotice": "Dieser Übungstyp wird in der aktuellen Ansicht noch nicht interaktiv dargestellt.",
"unknownExerciseTypeLabel": "Typ: {type}",
"lessonReviewHeadlineDone": "Diese Lektion ist in der freien Vertiefung angekommen.",

View File

@@ -825,6 +825,13 @@
"trainerProgressNewContent": "New content: {current}/{target}",
"trainerProgressReview": "Review: {count}",
"trainerProgressMixShare": "Mixed share: {percent}%",
"markVocabHard": "Mark as difficult",
"markVocabHardSaved": "Vocabulary marked as difficult.",
"unmarkVocabHard": "Remove from difficult list",
"unmarkVocabHardSaved": "Vocabulary removed from difficult list.",
"hardVocabModeActive": "Intensive block: difficult vocabulary",
"hardVocabRemaining": "Remaining until stable: {count}",
"startHardVocabTrainer": "Train difficult vocabulary ({count})",
"unknownExerciseTypeNotice": "This exercise type is not displayed interactively in the current view yet.",
"unknownExerciseTypeLabel": "Type: {type}",
"lessonReviewHeadlineDone": "This lesson has reached the free practice stage.",

View File

@@ -235,6 +235,13 @@
<button @click="startVocabTrainer" class="btn-start-trainer">
{{ hasPreviousVocab ? $t('socialnetwork.vocab.courses.startLesson') : $t('socialnetwork.vocab.courses.startVocabTrainer') }}
</button>
<button
v-if="hasCrossChapterHardVocab"
@click="startHardVocabTrainer"
class="btn-start-trainer button-secondary"
>
{{ $t('socialnetwork.vocab.courses.startHardVocabTrainer', { count: crossChapterHardVocab.length }) }}
</button>
</template>
<p v-else class="vocab-trainer-locked-hint">{{ $t('socialnetwork.vocab.courses.vocabTrainerLockedHint') }}</p>
</div>
@@ -271,6 +278,10 @@
</span>
<button @click="stopVocabTrainer" class="btn-stop-trainer">{{ $t('socialnetwork.vocab.courses.stopTrainer') }}</button>
</div>
<div v-if="vocabTrainerHardMode" class="stats-row trainer-progress-row">
<span>{{ $t('socialnetwork.vocab.courses.hardVocabModeActive') }}</span>
<span>{{ $t('socialnetwork.vocab.courses.hardVocabRemaining', { count: hardVocabRemainingCount }) }}</span>
</div>
<div v-if="hasPreviousVocab" class="stats-row trainer-progress-row">
<span>{{ $t('socialnetwork.vocab.courses.trainerProgressNewContent', { current: vocabTrainerCurrentAttempts, target: trainerNewFocusTarget }) }}</span>
<span>{{ $t('socialnetwork.vocab.courses.trainerProgressReview', { count: vocabTrainerReviewAttempts }) }}</span>
@@ -288,6 +299,19 @@
{{ $t('socialnetwork.vocab.courses.wrong') }}. {{ $t('socialnetwork.vocab.courses.correctAnswer') }}: {{ currentVocabQuestion.answer }}
</div>
</div>
<div class="vocab-hard-actions">
<button type="button" class="btn-switch-mode" @click="markCurrentVocabAsHard">
{{ $t('socialnetwork.vocab.courses.markVocabHard') }}
</button>
<button
v-if="isCurrentVocabMarkedHard"
type="button"
class="btn-switch-mode"
@click="unmarkCurrentVocabAsHard"
>
{{ $t('socialnetwork.vocab.courses.unmarkVocabHard') }}
</button>
</div>
<!-- Multiple Choice Modus -->
<div v-if="vocabTrainerMode === 'multiple_choice' && !vocabTrainerAnswered" class="vocab-answer-area multiple-choice">
<div class="choice-buttons">
@@ -1104,6 +1128,11 @@ export default {
vocabTrainerContinueTimer: null,
vocabTrainerLastWrongReview: null,
vocabTrainerDirection: 'L2R', // L2R: learning->reference, R2L: reference->learning
vocabTrainerSessionType: 'lesson', // 'lesson' | 'hard_collection'
vocabTrainerHardPool: [],
vocabTrainerHardMode: false,
vocabTrainerHardMastery: {}, // { [vocabKey]: consecutiveCorrectInHardMode }
manualHardVocabMap: {}, // persistente manuell markierte schwere Vokabeln je Lektion
isCheckingLessonCompletion: false, // Flag um Endlosschleife zu verhindern
isNavigatingToNext: false, // Flag um mehrfache Navigation zu verhindern
showNextLessonDialog: false,
@@ -1258,6 +1287,45 @@ export default {
}
return 0.5;
},
crossChapterHardVocab() {
const map = new Map();
const pushHard = (entry) => {
const learning = String(entry?.learning || '').trim();
const reference = String(entry?.reference || '').trim();
if (!this.isTrainableLessonVocabPair(learning, reference)) return;
const key = `${learning}|${reference}`;
const prev = map.get(key) || { learning, reference, wrongCount: 0 };
prev.wrongCount += Math.max(1, Number(entry?.wrongCount) || 1);
map.set(key, prev);
};
(this.courseProgressList || []).forEach((entry) => {
const weak = Array.isArray(entry?.lessonState?.reviewWeakVocab) ? entry.lessonState.reviewWeakVocab : [];
const manual = Array.isArray(entry?.lessonState?.manualHardVocab) ? entry.lessonState.manualHardVocab : [];
weak.forEach(pushHard);
manual.forEach(pushHard);
});
return Array.from(map.values())
.sort((a, b) => b.wrongCount - a.wrongCount)
.slice(0, 80)
.map((v) => ({ learning: v.learning, reference: v.reference }));
},
hasCrossChapterHardVocab() {
return this.crossChapterHardVocab.length > 0;
},
hardVocabRemainingCount() {
const requiredConsecutiveCorrect = 2;
return this.vocabTrainerHardPool.filter((vocab) => {
const key = this.getVocabKey(vocab);
return (Number(this.vocabTrainerHardMastery[key]) || 0) < requiredConsecutiveCorrect;
}).length;
},
isCurrentVocabMarkedHard() {
const key = this.currentVocabQuestion?.key || '';
if (!key) return false;
return Boolean(this.manualHardVocabMap[key]);
},
canAccessExercises() {
if (!this.hasExercises) return false;
if (this.exerciseNeedsReinforcement) return false;
@@ -1791,6 +1859,11 @@ export default {
vocabTrainerRepeatQueue: this.vocabTrainerRepeatQueue,
vocabTrainerCurrentAttempts: this.vocabTrainerCurrentAttempts,
vocabTrainerReviewAttempts: this.vocabTrainerReviewAttempts,
vocabTrainerSessionType: this.vocabTrainerSessionType,
vocabTrainerHardPool: this.vocabTrainerHardPool,
vocabTrainerHardMode: this.vocabTrainerHardMode,
vocabTrainerHardMastery: this.vocabTrainerHardMastery,
manualHardVocab: Object.values(this.manualHardVocabMap),
exerciseRetryPending: this.exerciseRetryPending,
exerciseRetryPendingSinceAttempts: this.exerciseRetryPendingSinceAttempts,
exerciseSequentialIndex: this.exerciseSequentialIndex
@@ -2104,6 +2177,26 @@ export default {
: {};
this.vocabTrainerCurrentAttempts = Math.max(0, Number(parsedState.vocabTrainerCurrentAttempts) || 0);
this.vocabTrainerReviewAttempts = Math.max(0, Number(parsedState.vocabTrainerReviewAttempts) || 0);
this.vocabTrainerSessionType = parsedState.vocabTrainerSessionType === 'hard_collection' ? 'hard_collection' : 'lesson';
this.vocabTrainerHardPool = Array.isArray(parsedState.vocabTrainerHardPool) ? parsedState.vocabTrainerHardPool : [];
this.vocabTrainerHardMode = Boolean(parsedState.vocabTrainerHardMode);
this.vocabTrainerHardMastery = parsedState.vocabTrainerHardMastery && typeof parsedState.vocabTrainerHardMastery === 'object'
? parsedState.vocabTrainerHardMastery
: {};
const savedManualHard = Array.isArray(parsedState.manualHardVocab) ? parsedState.manualHardVocab : [];
this.manualHardVocabMap = {};
savedManualHard.forEach((entry) => {
const learning = String(entry?.learning || '').trim();
const reference = String(entry?.reference || '').trim();
if (!this.isTrainableLessonVocabPair(learning, reference)) return;
const key = `${learning}|${reference}`;
this.manualHardVocabMap[key] = {
learning,
reference,
wrongCount: Math.max(1, Number(entry?.wrongCount) || 1),
lastWrongAt: String(entry?.lastWrongAt || new Date().toISOString())
};
});
this.exerciseRetryPending = Boolean(parsedState.exerciseRetryPending);
this.exerciseRetryPendingSinceAttempts = Math.max(0, Number(parsedState.exerciseRetryPendingSinceAttempts) || 0);
const maxIdx = Math.max(0, this.scrambledChapterExamExercises.length - 1);
@@ -2657,6 +2750,11 @@ export default {
this.vocabTrainerPool = [];
this.vocabTrainerMixedPool = [];
this.vocabTrainerRepeatQueue = [];
this.vocabTrainerHardPool = [];
this.vocabTrainerHardMode = false;
this.vocabTrainerHardMastery = {};
this.manualHardVocabMap = {};
this.vocabTrainerSessionType = 'lesson';
this.vocabTrainerPhase = 'current';
this.vocabTrainerCurrentAttempts = 0;
this.vocabTrainerReviewAttempts = 0;
@@ -3370,6 +3468,10 @@ export default {
this.vocabTrainerStats = {};
this.vocabTrainerRepeatQueue = [];
this.vocabTrainerLastWrongReview = null;
this.vocabTrainerSessionType = 'lesson';
this.vocabTrainerHardPool = [];
this.vocabTrainerHardMode = false;
this.vocabTrainerHardMastery = {};
// Bereite Mixed-Pool aus alten Vokabeln vor (ohne Duplikate aus aktueller Lektion)
this.vocabTrainerMixedPool = this._buildMixedPool();
this.vocabTrainerPhase = 'current';
@@ -3392,12 +3494,41 @@ export default {
this.vocabTrainerReviewAttempts = 0;
this.vocabTrainerMixedPool = [];
this.vocabTrainerRepeatQueue = [];
this.vocabTrainerHardPool = [];
this.vocabTrainerHardMode = false;
this.vocabTrainerHardMastery = {};
this.vocabTrainerSessionType = 'lesson';
this.currentVocabQuestion = null;
this.vocabTrainerAnswer = '';
this.vocabTrainerSelectedChoice = null;
this.vocabTrainerAnswered = false;
this.vocabTrainerLastWrongReview = null;
},
startHardVocabTrainer() {
const hardPool = this.crossChapterHardVocab.slice();
if (!hardPool.length) return;
this.vocabTrainerActive = true;
this.vocabTrainerMode = 'multiple_choice';
this.vocabTrainerAutoSwitchedToTyping = false;
this.vocabTrainerCorrect = 0;
this.vocabTrainerWrong = 0;
this.vocabTrainerTotalAttempts = 0;
this.vocabTrainerCurrentAttempts = 0;
this.vocabTrainerReviewAttempts = 0;
this.vocabTrainerStats = {};
this.vocabTrainerRepeatQueue = [];
this.vocabTrainerSessionType = 'hard_collection';
this.vocabTrainerHardPool = hardPool;
this.vocabTrainerHardMode = true;
this.vocabTrainerHardMastery = {};
this.vocabTrainerMixedPool = [];
this.vocabTrainerPool = hardPool.slice();
this.currentVocabQuestion = null;
this.vocabTrainerLastWrongReview = null;
this.$nextTick(() => {
this.nextVocabQuestion();
});
},
/** Erstellt den Mixed-Pool aus vorherigen Lektions-Vokabeln (ohne Duplikate der aktuellen Lektion) */
_buildMixedPool() {
if (!this.previousVocab || this.previousVocab.length === 0) return [];
@@ -3444,6 +3575,50 @@ export default {
getVocabKey(vocab) {
return `${vocab.learning}|${vocab.reference}`;
},
markCurrentVocabAsHard() {
if (!this.currentVocabQuestion?.vocab) return;
const vocab = this.currentVocabQuestion.vocab;
const key = this.getVocabKey(vocab);
this.manualHardVocabMap[key] = {
learning: String(vocab.learning || '').trim(),
reference: String(vocab.reference || '').trim(),
wrongCount: Math.max(1, Number(this.manualHardVocabMap[key]?.wrongCount) || 1),
lastWrongAt: new Date().toISOString()
};
if (!this.vocabTrainerHardPool.some((entry) => this.getVocabKey(entry) === key)) {
this.vocabTrainerHardPool.push(vocab);
}
if (this.vocabTrainerHardMastery[key] == null) {
this.vocabTrainerHardMastery[key] = 0;
}
this.$root?.$refs?.messageDialog?.open?.('tr:socialnetwork.vocab.courses.markVocabHardSaved');
},
unmarkCurrentVocabAsHard() {
if (!this.currentVocabQuestion?.key) return;
const key = this.currentVocabQuestion.key;
delete this.manualHardVocabMap[key];
this.vocabTrainerHardPool = this.vocabTrainerHardPool.filter((entry) => this.getVocabKey(entry) !== key);
delete this.vocabTrainerHardMastery[key];
this.$root?.$refs?.messageDialog?.open?.('tr:socialnetwork.vocab.courses.unmarkVocabHardSaved');
this.maybeExitHardIntensivePhase();
},
maybeEnterHardIntensivePhase() {
if (this.vocabTrainerSessionType !== 'lesson') return;
if (this.vocabTrainerHardMode) return;
if (!this.vocabTrainerHardPool.length) return;
if (this.vocabTrainerCurrentAttempts < this.trainerNewFocusTarget) return;
this.vocabTrainerHardMode = true;
},
maybeExitHardIntensivePhase() {
if (!this.vocabTrainerHardMode) return;
if (!this.vocabTrainerHardPool.length) {
this.vocabTrainerHardMode = false;
return;
}
if (this.hardVocabRemainingCount <= 0) {
this.vocabTrainerHardMode = false;
}
},
getVocabStats(vocab) {
const key = this.getVocabKey(vocab);
if (!this.vocabTrainerStats[key]) {
@@ -3770,6 +3945,68 @@ export default {
// Prüfe ob Modus-Wechsel nötig ist
this.checkVocabModeSwitch();
this.maybeEnterHardIntensivePhase();
this.maybeExitHardIntensivePhase();
if (this.vocabTrainerSessionType === 'hard_collection' || this.vocabTrainerHardMode) {
const requiredConsecutiveCorrect = 2;
const pool = this.vocabTrainerHardPool.filter((vocab) => {
const key = this.getVocabKey(vocab);
return (Number(this.vocabTrainerHardMastery[key]) || 0) < requiredConsecutiveCorrect;
});
if (!pool.length) {
this.vocabTrainerHardMode = false;
if (this.vocabTrainerSessionType === 'hard_collection') {
this.currentVocabQuestion = null;
this.vocabTrainerActive = false;
return;
}
} else {
const vocab = this.chooseVocabFromPool(pool);
if (!vocab) {
this.currentVocabQuestion = null;
return;
}
this.vocabTrainerDirection = Math.random() < 0.5 ? 'L2R' : 'R2L';
const allTrainerVocabs = pool;
const direction = this.vocabTrainerDirection;
const prompt = direction === 'L2R' ? vocab.learning : vocab.reference;
const acceptableAnswers = this.getEquivalentVocabAnswers(prompt, direction, allTrainerVocabs);
this.currentVocabQuestion = {
vocab,
prompt,
answers: acceptableAnswers.length > 0
? acceptableAnswers
: [direction === 'L2R' ? vocab.reference : vocab.learning],
answer: acceptableAnswers.length > 0
? acceptableAnswers.join(' / ')
: (direction === 'L2R' ? vocab.reference : vocab.learning),
key: this.getVocabKey(vocab),
source: 'hard'
};
this.vocabTrainerAnswer = '';
this.vocabTrainerSelectedChoice = null;
this.vocabTrainerAnswered = false;
if (this.vocabTrainerMode === 'multiple_choice') {
this.vocabTrainerChoiceOptions = this.buildChoiceOptions(
this.currentVocabQuestion.answers,
allTrainerVocabs,
this.currentVocabQuestion.prompt,
this.vocabTrainerDirection
);
}
if (this.vocabTrainerMode === 'typing') {
this.$nextTick(() => {
this.$refs.vocabInput?.focus?.();
});
}
return;
}
}
let questionSource = 'current';
let sourcePool = this.trainableLessonVocab;
@@ -3969,6 +4206,17 @@ export default {
this.vocabTrainerCorrect++;
stats.correct++;
this.resolveRepeatedVocab(this.currentVocabQuestion.vocab);
if (this.currentVocabQuestion.source === 'hard') {
const key = this.currentVocabQuestion.key;
const nextMastery = Math.max(0, Number(this.vocabTrainerHardMastery[key]) || 0) + 1;
this.vocabTrainerHardMastery[key] = nextMastery;
// Wenn eine manuell markierte schwierige Vokabel mehrfach korrekt sitzt,
// automatisch aus der manuellen Schwerliste entfernen.
if (nextMastery >= 2 && this.manualHardVocabMap[key]) {
delete this.manualHardVocabMap[key];
this.vocabTrainerHardPool = this.vocabTrainerHardPool.filter((entry) => this.getVocabKey(entry) !== key);
}
}
} else {
this.vocabTrainerLastWrongReview = {
prompt: this.currentVocabQuestion.prompt,
@@ -3978,6 +4226,10 @@ export default {
this.vocabTrainerWrong++;
stats.wrong++;
this.queueFailedVocab(this.currentVocabQuestion.vocab);
if (this.currentVocabQuestion.source === 'hard') {
const key = this.currentVocabQuestion.key;
this.vocabTrainerHardMastery[key] = 0;
}
}
this.reportSrsReviewForCurrentQuestion(this.vocabTrainerLastCorrect);