diff --git a/backend/models/falukant/log/dayproduction.js b/backend/models/falukant/log/dayproduction.js
index 46ea373..439b692 100644
--- a/backend/models/falukant/log/dayproduction.js
+++ b/backend/models/falukant/log/dayproduction.js
@@ -23,7 +23,11 @@ DayProduction.init({
productionDate: {
type: DataTypes.DATEONLY,
allowNull: false,
- defaultValue: sequelize.literal('CURRENT_DATE')}
+ defaultValue: sequelize.literal('CURRENT_DATE')},
+ completionCount: {
+ type: DataTypes.INTEGER,
+ allowNull: false,
+ defaultValue: 1}
}, {
sequelize,
modelName: 'DayProduction',
diff --git a/backend/services/falukantService.js b/backend/services/falukantService.js
index efe7855..2f77410 100644
--- a/backend/services/falukantService.js
+++ b/backend/services/falukantService.js
@@ -2968,7 +2968,9 @@ class FalukantService extends BaseService {
/**
* Zertifikat: abgeschlossene Produktionen über alle Regionen/Niederlassungen.
- * Es zählt jede abgeschlossene Produktion (ein Datensatz in falukant_log.production).
+ * Es zählt jede abgeschlossene Produktion.
+ * Seit Daemon-Migration über falukant_log.production.completion_count
+ * (aggregierte Zeilen => SUM(completion_count), nicht COUNT(*)).
* Filter bei gesetztem countSince wie Daemon (GET_PRODUCTION_CERTIFICATE_INPUT_ROWS):
* COALESCE(production_timestamp, production_date::timestamp) >= countSince.
*
@@ -2983,7 +2985,7 @@ class FalukantService extends BaseService {
if (countSince) replacements.countSince = countSince;
const rows = await sequelize.query(
`
- SELECT COUNT(*)::int AS cnt
+ SELECT COALESCE(SUM(COALESCE(completion_count, 1)), 0)::int AS cnt
FROM falukant_log.production
WHERE producer_id = :producerId${sinceClause}
`,
diff --git a/frontend/src/i18n/locales/de/socialnetwork.json b/frontend/src/i18n/locales/de/socialnetwork.json
index b8b5ff1..e52e9ef 100644
--- a/frontend/src/i18n/locales/de/socialnetwork.json
+++ b/frontend/src/i18n/locales/de/socialnetwork.json
@@ -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.",
diff --git a/frontend/src/i18n/locales/en/socialnetwork.json b/frontend/src/i18n/locales/en/socialnetwork.json
index d52268e..d2f4f2a 100644
--- a/frontend/src/i18n/locales/en/socialnetwork.json
+++ b/frontend/src/i18n/locales/en/socialnetwork.json
@@ -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.",
diff --git a/frontend/src/views/social/VocabLessonView.vue b/frontend/src/views/social/VocabLessonView.vue
index a22d679..527c311 100644
--- a/frontend/src/views/social/VocabLessonView.vue
+++ b/frontend/src/views/social/VocabLessonView.vue
@@ -235,6 +235,13 @@
+
{{ $t('socialnetwork.vocab.courses.vocabTrainerLockedHint') }}
@@ -271,6 +278,10 @@
+
+ {{ $t('socialnetwork.vocab.courses.hardVocabModeActive') }}
+ {{ $t('socialnetwork.vocab.courses.hardVocabRemaining', { count: hardVocabRemainingCount }) }}
+
{{ $t('socialnetwork.vocab.courses.trainerProgressNewContent', { current: vocabTrainerCurrentAttempts, target: trainerNewFocusTarget }) }}
{{ $t('socialnetwork.vocab.courses.trainerProgressReview', { count: vocabTrainerReviewAttempts }) }}
@@ -288,6 +299,19 @@
{{ $t('socialnetwork.vocab.courses.wrong') }}. {{ $t('socialnetwork.vocab.courses.correctAnswer') }}: {{ currentVocabQuestion.answer }}
+
+
+
+
@@ -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);