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);