diff --git a/backend/scripts/bisaya-course-phase1.js b/backend/scripts/bisaya-course-phase1.js
new file mode 100644
index 0000000..d15c38c
--- /dev/null
+++ b/backend/scripts/bisaya-course-phase1.js
@@ -0,0 +1,24 @@
+export const BISAYA_PHASE1_DIDACTICS = {
+ 'Begrüßungen & Höflichkeit': {
+ corePatterns: [
+ { target: 'Kumusta ka?', gloss: 'Wie geht es dir?' },
+ { target: 'Maayong buntag.', gloss: 'Guten Morgen.' },
+ { target: 'Maayong adlaw.', gloss: 'Guten Tag.' },
+ { target: 'Maayong gabii.', gloss: 'Guten Abend.' },
+ { target: 'Maayong gabii, matulog na ta.', gloss: 'Guten Abend, wir legen uns schlafen.' },
+ { target: 'Katulog og maayo.', gloss: 'Schlaf gut.' },
+ { target: 'Kapoy na ka?', gloss: 'Bist du müde?' },
+ { target: 'Matulog na ta.', gloss: 'Lass uns schlafen gehen.' },
+ { target: 'Inom sa og tubig.', gloss: 'Trink Wasser.' },
+ { target: 'Patya ang suga.', gloss: 'Mach das Licht aus.' },
+ { target: 'Tabuni ang imong kaugalingon.', gloss: 'Deck dich zu.' },
+ { target: 'Ugma nasad.', gloss: 'Bis morgen wieder.' },
+ { target: 'Damgo og nindot.', gloss: 'Träum schön.' },
+ { target: 'Amping.', gloss: 'Pass auf dich auf.' },
+ { target: 'Babay.', gloss: 'Tschüss.' },
+ { target: 'Maayo ko.', gloss: 'Mir geht es gut.' },
+ { target: 'Salamat.', gloss: 'Danke.' },
+ { target: 'Palihug.', gloss: 'Bitte.' }
+ ]
+ }
+};
diff --git a/backend/services/vocabService.js b/backend/services/vocabService.js
index 36f5c24..d0db972 100644
--- a/backend/services/vocabService.js
+++ b/backend/services/vocabService.js
@@ -12,6 +12,7 @@ import UserParam from '../models/community/user_param.js';
import { sequelize } from '../utils/sequelize.js';
import { notifyUser } from '../utils/socket.js';
import { Op } from 'sequelize';
+import { BISAYA_PHASE1_DIDACTICS } from '../scripts/bisaya-course-phase1.js';
export default class VocabService {
async _getUserByHashedId(hashedUserId) {
@@ -347,6 +348,27 @@ export default class VocabService {
});
}
+ _mergeCorePatternGlosses(primaryPatterns = [], fallbackPatterns = []) {
+ const fallbackByTarget = new Map(
+ fallbackPatterns
+ .map((entry) => this._normalizeCorePatternEntry(entry))
+ .filter(Boolean)
+ .map((entry) => [this._normalizeLexeme(entry.target), entry.gloss || ''])
+ );
+
+ return primaryPatterns.map((entry) => {
+ const normalized = this._normalizeCorePatternEntry(entry);
+ if (!normalized) {
+ return null;
+ }
+ if (normalized.gloss) {
+ return normalized;
+ }
+ const gloss = fallbackByTarget.get(this._normalizeLexeme(normalized.target)) || '';
+ return gloss ? { ...normalized, gloss } : normalized;
+ }).filter(Boolean);
+ }
+
_normalizeStructuredList(value, keys = ['title', 'text']) {
if (!value) return [];
if (Array.isArray(value)) {
@@ -560,9 +582,13 @@ export default class VocabService {
const learningGoals = this._normalizeStringList(plainLesson.learningGoals);
const extractedTrainerVocabs = this._extractTrainerVocabsFromExercises(grammarExercises);
- const corePatterns = this._enrichCorePatternsWithGloss(
- this._normalizeCorePatternList(plainLesson.corePatterns),
- extractedTrainerVocabs
+ const phase1FallbackCorePatterns = BISAYA_PHASE1_DIDACTICS[plainLesson.title]?.corePatterns || [];
+ const corePatterns = this._mergeCorePatternGlosses(
+ this._enrichCorePatternsWithGloss(
+ this._normalizeCorePatternList(plainLesson.corePatterns),
+ extractedTrainerVocabs
+ ),
+ phase1FallbackCorePatterns
);
const grammarFocus = this._normalizeStructuredList(plainLesson.grammarFocus, ['title', 'text', 'example']);
const explicitSpeakingPrompts = this._normalizeStructuredList(plainLesson.speakingPrompts, ['title', 'prompt', 'cue']);
@@ -578,9 +604,12 @@ export default class VocabService {
],
corePatterns: corePatterns.length > 0
? corePatterns
- : this._enrichCorePatternsWithGloss(
- uniquePatterns.slice(0, 5).map((s) => ({ target: String(s || '').trim(), gloss: '' })).filter((p) => p.target),
- extractedTrainerVocabs
+ : this._mergeCorePatternGlosses(
+ this._enrichCorePatternsWithGloss(
+ uniquePatterns.slice(0, 5).map((s) => ({ target: String(s || '').trim(), gloss: '' })).filter((p) => p.target),
+ extractedTrainerVocabs
+ ),
+ phase1FallbackCorePatterns
),
grammarFocus: grammarFocus.length > 0 ? grammarFocus : uniqueGrammarExplanations.slice(0, 4),
speakingPrompts: explicitSpeakingPrompts.length > 0 ? explicitSpeakingPrompts : speakingPrompts.slice(0, 4),
diff --git a/frontend/src/i18n/locales/de/socialnetwork.json b/frontend/src/i18n/locales/de/socialnetwork.json
index 4faeaf6..bae3467 100644
--- a/frontend/src/i18n/locales/de/socialnetwork.json
+++ b/frontend/src/i18n/locales/de/socialnetwork.json
@@ -456,7 +456,9 @@
"vocabPrepStep2": "Gehe die gleichen Begriffe noch einmal durch (aktive Wiederholung, ohne zu üben).",
"vocabPrepConfirm2": "Zweite Durchsicht erledigt",
"vocabPrepReady": "Du kannst jetzt mit dem Vokabeltrainer starten.",
+ "vocabOverviewToggle": "Gesamtübersicht der Begriffe anzeigen",
"vocabTrainerLockedHint": "Bitte bestätige zuerst zwei Lern-Durchgänge bei „Vorbereitung vor dem Vokabeltrainer“.",
+ "exerciseUnlockHintAfterPrep": "Arbeite zuerst die vorbereiteten Begriffe durch. Danach wird die Kapitel-Prüfung freigeschaltet.",
"speakingTasks": "Sprechaufträge",
"speakingPrompt": "Sprechauftrag",
"practicalTasks": "Praxisaufgaben",
diff --git a/frontend/src/i18n/locales/en/socialnetwork.json b/frontend/src/i18n/locales/en/socialnetwork.json
index ee7559d..81eb85c 100644
--- a/frontend/src/i18n/locales/en/socialnetwork.json
+++ b/frontend/src/i18n/locales/en/socialnetwork.json
@@ -456,7 +456,9 @@
"vocabPrepStep2": "Go through the same items again (active review, not testing yet).",
"vocabPrepConfirm2": "Second pass done",
"vocabPrepReady": "You can start the vocabulary trainer now.",
+ "vocabOverviewToggle": "Show full overview of terms",
"vocabTrainerLockedHint": "Please confirm two preparation steps under “Preparation before the vocabulary trainer” first.",
+ "exerciseUnlockHintAfterPrep": "Work through the prepared terms first. The chapter test will unlock afterwards.",
"speakingTasks": "Speaking Tasks",
"speakingPrompt": "Speaking Prompt",
"practicalTasks": "Practical Tasks",
diff --git a/frontend/src/i18n/locales/es/socialnetwork.json b/frontend/src/i18n/locales/es/socialnetwork.json
index 065fa1b..9c52d20 100644
--- a/frontend/src/i18n/locales/es/socialnetwork.json
+++ b/frontend/src/i18n/locales/es/socialnetwork.json
@@ -454,7 +454,9 @@
"vocabPrepStep2": "Repasa los mismos elementos otra vez (repaso activo, aún sin practicar).",
"vocabPrepConfirm2": "Segunda lectura hecha",
"vocabPrepReady": "Ya puedes iniciar el entrenador de vocabulario.",
+ "vocabOverviewToggle": "Mostrar vista general completa de los términos",
"vocabTrainerLockedHint": "Confirma primero los dos pasos de preparación arriba.",
+ "exerciseUnlockHintAfterPrep": "Primero recorre los términos preparados. Después se desbloqueará la prueba del capítulo.",
"speakingTasks": "Tareas orales",
"speakingPrompt": "Tarea oral",
"practicalTasks": "Tareas prácticas",
diff --git a/frontend/src/views/social/VocabLessonView.vue b/frontend/src/views/social/VocabLessonView.vue
index 51c710f..c85ceae 100644
--- a/frontend/src/views/social/VocabLessonView.vue
+++ b/frontend/src/views/social/VocabLessonView.vue
@@ -247,24 +247,27 @@
{{ $t('socialnetwork.vocab.courses.vocabPrepReady') }}
-
-
+
- {{ $t('socialnetwork.vocab.courses.importantVocab') }}
+
+ {{ $t('socialnetwork.vocab.courses.vocabOverviewToggle') }}
+
{{ $t('socialnetwork.vocab.courses.vocabInfoText') }}
-
-
{{ vocab.learning }}
+
+ {{ vocab.learning || '—' }}
→
{{ vocab.reference }}
-
+
-
+
{{ $t('socialnetwork.vocab.courses.vocabTrainer') }}
Wiederholung läuft schrittweise mit
@@ -367,7 +370,7 @@
-
+
{{ $t('socialnetwork.vocab.courses.noVocabInfo') }}
@@ -830,7 +833,7 @@ export default {
return 1;
},
trainerNewFocusTarget() {
- const vocabCount = this.importantVocab?.length || 0;
+ const vocabCount = this.trainableLessonVocab?.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);
@@ -858,9 +861,16 @@ export default {
canAccessExercises() {
if (!this.hasExercises) return false;
const isReview = this.lesson?.lessonType === 'review' || this.lesson?.lessonType === 'vocab_review';
- return isReview || this.exercisePreparationCompleted;
+ if (isReview) return true;
+ if (this.trainableLessonVocab.length === 0 && this.prepItems.length > 0) {
+ return this.lessonPrepStage >= 2;
+ }
+ return this.exercisePreparationCompleted;
},
exerciseUnlockHint() {
+ if (this.trainableLessonVocab.length === 0 && this.prepItems.length > 0) {
+ return this.$t('socialnetwork.vocab.courses.exerciseUnlockHintAfterPrep');
+ }
if (this.hasPreviousVocab) {
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.`;
}
@@ -931,6 +941,39 @@ export default {
return [];
}
},
+ lessonVocab() {
+ const vocabByReference = new Map();
+ const addEntry = (entry) => {
+ const reference = String(entry?.reference || '').trim();
+ const learning = String(entry?.learning || '').trim();
+ if (!reference) return;
+ const key = reference.toLowerCase();
+ if (!vocabByReference.has(key)) {
+ vocabByReference.set(key, { learning, reference });
+ return;
+ }
+ const existing = vocabByReference.get(key);
+ if (!existing.learning && learning) {
+ existing.learning = learning;
+ }
+ };
+
+ this.normalizedCorePatterns.forEach((item) => {
+ addEntry({
+ learning: item.gloss || '',
+ reference: item.target || ''
+ });
+ });
+
+ this.importantVocab.forEach((item) => {
+ addEntry(item);
+ });
+
+ return Array.from(vocabByReference.values());
+ },
+ trainableLessonVocab() {
+ return this.lessonVocab.filter((entry) => entry.learning && entry.reference && entry.learning !== entry.reference);
+ },
lessonDidactics() {
return this.lesson?.didactics || {
learningGoals: [],
@@ -947,21 +990,7 @@ export default {
.filter(Boolean);
},
prepItems() {
- if (this.normalizedCorePatterns.length > 0) {
- const glossByReference = new Map(
- (this.importantVocab || [])
- .map((item) => [String(item?.reference || '').trim().toLowerCase(), String(item?.learning || '').trim()])
- .filter(([reference, learning]) => reference && learning)
- );
- return this.normalizedCorePatterns.map((item) => {
- if (item.gloss) {
- return item;
- }
- const gloss = glossByReference.get(String(item.target || '').trim().toLowerCase()) || '';
- return gloss ? { ...item, gloss } : item;
- });
- }
- return (this.importantVocab || [])
+ return this.lessonVocab
.map((item) => ({
target: String(item?.reference || '').trim(),
gloss: String(item?.learning || '').trim()
@@ -981,7 +1010,7 @@ export default {
return this.normalizedCorePatterns.some((p) => p.gloss);
},
canStartVocabTrainerPrep() {
- if (!this.importantVocab || this.importantVocab.length === 0) {
+ if (!this.trainableLessonVocab || this.trainableLessonVocab.length === 0) {
return false;
}
if (this.prepItems.length > 0 && this.lessonPrepStage < 2) {
@@ -1794,17 +1823,17 @@ export default {
// Vokabeltrainer-Methoden
startVocabTrainer() {
debugLog('[VocabLessonView] startVocabTrainer aufgerufen');
- if (!this.importantVocab || this.importantVocab.length === 0) {
+ if (!this.trainableLessonVocab || this.trainableLessonVocab.length === 0) {
debugLog('[VocabLessonView] Keine Vokabeln vorhanden');
return;
}
if (!this.canStartVocabTrainerPrep) {
return;
}
- debugLog('[VocabLessonView] Vokabeln gefunden:', this.importantVocab.length);
+ debugLog('[VocabLessonView] Vokabeln gefunden:', this.trainableLessonVocab.length);
debugLog('[VocabLessonView] Alte Vokabeln:', this.previousVocab?.length || 0);
this.vocabTrainerActive = true;
- this.vocabTrainerPool = [...this.importantVocab];
+ this.vocabTrainerPool = [...this.trainableLessonVocab];
this.vocabTrainerMode = 'multiple_choice';
this.vocabTrainerAutoSwitchedToTyping = false;
this.vocabTrainerCorrect = 0;
@@ -1817,7 +1846,7 @@ export default {
this.vocabTrainerMixedPool = this._buildMixedPool();
this.vocabTrainerPhase = 'current';
this.vocabTrainerMixedAttempts = 0;
- this.vocabTrainerPool = [...this.importantVocab];
+ this.vocabTrainerPool = [...this.trainableLessonVocab];
debugLog('[VocabLessonView] Mixed-Pool:', this.vocabTrainerMixedPool.length, 'Vokabeln');
debugLog('[VocabLessonView] Rufe nextVocabQuestion auf');
this.$nextTick(() => {
@@ -1841,7 +1870,7 @@ export default {
/** Erstellt den Mixed-Pool aus vorherigen Lektions-Vokabeln (ohne Duplikate der aktuellen Lektion) */
_buildMixedPool() {
if (!this.previousVocab || this.previousVocab.length === 0) return [];
- const currentKeys = new Set(this.importantVocab.map(v => this.getVocabKey(v)));
+ const currentKeys = new Set(this.trainableLessonVocab.map(v => this.getVocabKey(v)));
const filtered = this.previousVocab.filter(v => !currentKeys.has(this.getVocabKey(v)));
// Zufällig mischen und auf 40 begrenzen
const shuffled = [...filtered].sort(() => Math.random() - 0.5);
@@ -1868,7 +1897,7 @@ export default {
debugLog('[VocabLessonView] Mixed-Phase abgeschlossen, wechsle zu Typing');
this.vocabTrainerMode = 'typing';
this.vocabTrainerAutoSwitchedToTyping = true;
- this.vocabTrainerPool = [...this.importantVocab, ...this.vocabTrainerMixedPool];
+ this.vocabTrainerPool = [...this.trainableLessonVocab, ...this.vocabTrainerMixedPool];
this.vocabTrainerCorrect = 0;
this.vocabTrainerWrong = 0;
this.vocabTrainerTotalAttempts = 0;
@@ -1880,8 +1909,8 @@ export default {
this.vocabTrainerMode = 'multiple_choice';
this.vocabTrainerAutoSwitchedToTyping = false;
this.vocabTrainerPool = this.vocabTrainerPhase === 'mixed'
- ? [...this.importantVocab, ...this.vocabTrainerMixedPool]
- : [...this.importantVocab];
+ ? [...this.trainableLessonVocab, ...this.vocabTrainerMixedPool]
+ : [...this.trainableLessonVocab];
// Reset Stats für Multiple Choice Modus
this.vocabTrainerCorrect = 0;
this.vocabTrainerWrong = 0;
@@ -1988,7 +2017,7 @@ export default {
this.checkVocabModeSwitch();
let questionSource = 'current';
- let sourcePool = this.importantVocab;
+ let sourcePool = this.trainableLessonVocab;
if (this.vocabTrainerMode === 'typing') {
sourcePool = this.vocabTrainerPool;
@@ -2001,14 +2030,14 @@ export default {
}
if (!sourcePool || sourcePool.length === 0) {
- sourcePool = this.importantVocab;
+ sourcePool = this.trainableLessonVocab;
questionSource = 'current';
}
const randomIndex = Math.floor(Math.random() * sourcePool.length);
const vocab = sourcePool[randomIndex];
this.vocabTrainerDirection = Math.random() < 0.5 ? 'L2R' : 'R2L';
- const allTrainerVocabs = [...this.importantVocab, ...this.vocabTrainerMixedPool];
+ const allTrainerVocabs = [...this.trainableLessonVocab, ...this.vocabTrainerMixedPool];
const prompt = this.vocabTrainerDirection === 'L2R' ? vocab.learning : vocab.reference;
const acceptableAnswers = this.getEquivalentVocabAnswers(
prompt,
@@ -2795,6 +2824,17 @@ export default {
margin: 20px 0;
}
+.vocab-list__summary {
+ cursor: pointer;
+ font-weight: 600;
+ color: #5f4313;
+ margin-bottom: 12px;
+}
+
+.vocab-list[open] .vocab-list__summary {
+ margin-bottom: 14px;
+}
+
.vocab-list h4 {
margin-bottom: 15px;
color: #333;