diff --git a/frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue b/frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue index f7eee55..b485e5d 100644 --- a/frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue +++ b/frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue @@ -199,6 +199,9 @@ export default { locked: false, autoAdvanceTimer: null, hardVocabMap: {}, // { [normalizedPairKey]: { learning, reference, markedAt } } + hardPhaseActive: false, + hardMasteryByKey: {}, // { [hardKey]: consecutiveCorrect } + cycleAskedIds: [], }; }, computed: { @@ -294,6 +297,17 @@ export default { isCurrentMarkedHard() { if (!this.current) return false; return Boolean(this.hardVocabMap[this.getHardKey(this.current)]); + }, + hardPoolItems() { + if (!Array.isArray(this.pool) || this.pool.length === 0) return []; + return this.pool.filter((item) => Boolean(this.hardVocabMap[this.getHardKey(item)])); + }, + hardRemainingCount() { + const requiredConsecutiveCorrect = 2; + return this.hardPoolItems.filter((item) => { + const key = this.getHardKey(item); + return (Number(this.hardMasteryByKey[key]) || 0) < requiredConsecutiveCorrect; + }).length; } }, methods: { @@ -341,6 +355,9 @@ export default { markedAt: new Date().toISOString() } }; + if (this.hardMasteryByKey[key] == null) { + this.hardMasteryByKey[key] = 0; + } this.saveHardVocabMap(); }, unmarkCurrentAsHard() { @@ -350,8 +367,33 @@ export default { const next = { ...this.hardVocabMap }; delete next[key]; this.hardVocabMap = next; + if (this.hardMasteryByKey[key] != null) { + const nextMastery = { ...this.hardMasteryByKey }; + delete nextMastery[key]; + this.hardMasteryByKey = nextMastery; + } + if (this.hardPhaseActive && this.hardRemainingCount <= 0) { + this.hardPhaseActive = false; + } this.saveHardVocabMap(); }, + addCurrentToCycle() { + if (!this.current?.id) return; + if (this.cycleAskedIds.includes(this.current.id)) return; + this.cycleAskedIds = [...this.cycleAskedIds, this.current.id]; + }, + maybeStartHardPhase() { + if (this.srsMode || this.hardPhaseActive) return; + if (!this.pool.length || !this.hardPoolItems.length) return; + if (this.cycleAskedIds.length < this.pool.length) return; + this.hardPhaseActive = true; + }, + maybeFinishHardPhase() { + if (!this.hardPhaseActive) return; + if (this.hardRemainingCount > 0) return; + this.hardPhaseActive = false; + this.cycleAskedIds = []; + }, getLocalDateKey() { const d = new Date(); const y = d.getFullYear(); @@ -455,14 +497,17 @@ export default { this.pendingRetry = null; this.pool = []; this.hardVocabMap = {}; + this.hardPhaseActive = false; + this.hardMasteryByKey = {}; + this.cycleAskedIds = []; this.locked = false; this.resetQuestion(); this.$refs.dialog.open(); this.$nextTick(() => { document.addEventListener('keydown', this.handleKeyDown); }); - this.reloadPool(); this.loadHardVocabMap(); + this.reloadPool(); }, close() { if (this.autoAdvanceTimer) { @@ -736,6 +781,36 @@ export default { if (!nextId) return null; return items.find((it) => it.id === nextId) || null; } + if (this.hardPhaseActive) { + const requiredConsecutiveCorrect = 2; + const hardItems = this.hardPoolItems.filter((item) => { + const key = this.getHardKey(item); + return (Number(this.hardMasteryByKey[key]) || 0) < requiredConsecutiveCorrect; + }); + if (hardItems.length === 0) { + this.hardPhaseActive = false; + return null; + } + const rankedHard = hardItems + .map((item) => { + const key = this.getHardKey(item); + const mastery = Number(this.hardMasteryByKey[key]) || 0; + const st = this.perId[item.id] || { c: 0, w: 0, streak: 0, lastAsked: 0 }; + return { + item, + mastery, + wrong: Number(st.w) || 0, + attempts: (Number(st.c) || 0) + (Number(st.w) || 0), + }; + }) + .sort((a, b) => { + if (a.mastery !== b.mastery) return a.mastery - b.mastery; + if (a.wrong !== b.wrong) return b.wrong - a.wrong; + if (a.attempts !== b.attempts) return a.attempts - b.attempts; + return Math.random() - 0.5; + }); + return rankedHard[0]?.item || hardItems[Math.floor(Math.random() * hardItems.length)]; + } if (this.pendingRetry?.id) { const retryItem = items.find((it) => it.id === this.pendingRetry.id); if (retryItem) { @@ -861,9 +936,23 @@ export default { if (isCorrect) { st.c += 1; st.streak = st.streak >= 0 ? st.streak + 1 : 1; + if (this.hardPhaseActive && this.current) { + const key = this.getHardKey(this.current); + this.hardMasteryByKey[key] = Math.max(0, Number(this.hardMasteryByKey[key]) || 0) + 1; + if ((Number(this.hardMasteryByKey[key]) || 0) >= 2 && this.hardVocabMap[key]) { + const next = { ...this.hardVocabMap }; + delete next[key]; + this.hardVocabMap = next; + this.saveHardVocabMap(); + } + } } else { st.w += 1; st.streak = st.streak <= 0 ? st.streak - 1 : -1; + if (this.hardPhaseActive && this.current) { + const key = this.getHardKey(this.current); + this.hardMasteryByKey[key] = 0; + } } st.lastAsked = Date.now(); this.perId[id] = st; @@ -943,12 +1032,23 @@ export default { this.resetQuestion(); return; } + if (this.current?.id) { + this.addCurrentToCycle(); + } + this.maybeStartHardPhase(); + this.maybeFinishHardPhase(); const retryDirection = this.pendingRetry?.direction || null; this.resetQuestion(); if (retryDirection) { this.direction = retryDirection; } this.current = this.pickNextItem(); + if (!this.current) { + this.maybeFinishHardPhase(); + if (!this.hardPhaseActive) { + this.current = this.pickNextItem(); + } + } if (!this.current) return; const prompt = this.currentPrompt; this.acceptableAnswers = this.getAnswersForPrompt(prompt, this.direction); diff --git a/frontend/src/i18n/locales/ceb/socialnetwork.json b/frontend/src/i18n/locales/ceb/socialnetwork.json index 42ea763..7f9d93c 100644 --- a/frontend/src/i18n/locales/ceb/socialnetwork.json +++ b/frontend/src/i18n/locales/ceb/socialnetwork.json @@ -503,6 +503,13 @@ "trainerProgressNewContent": "Bag-ong sulod: {current}/{target}", "trainerProgressReview": "Balik-balik: {count}", "trainerProgressMixShare": "Nasagol nga bahin: {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": "Kini nga matang sa ehersisyo wala pa ipakita nga interaktibo sa kasamtangang view.", "unknownExerciseTypeLabel": "Matang: {type}", "lessonReviewHeadlineDone": "Nakaabot na kini nga leksiyon sa libre nga pagpalalom.", @@ -806,6 +813,9 @@ "remaining": "Nahibilin", "success": "Malampuson", "fail": "Fail", + "hardCount": "Marked difficult", + "markHard": "Mark as difficult", + "unmarkHard": "Remove difficult mark", "srsRateTitle": "Unsa ka lig-on sa imong pagbati?", "srsAgain": "Usab", "srsAgainHint": "balikon dayon", diff --git a/frontend/src/i18n/locales/es/socialnetwork.json b/frontend/src/i18n/locales/es/socialnetwork.json index 64eea41..1b525b2 100644 --- a/frontend/src/i18n/locales/es/socialnetwork.json +++ b/frontend/src/i18n/locales/es/socialnetwork.json @@ -457,7 +457,10 @@ "askedVocab": "Preguntado:", "stats": "Estadísticas", "success": "Éxito", - "fail": "Fallo" + "fail": "Fallo", + "hardCount": "Marcadas como difíciles", + "markHard": "Marcar como difícil", + "unmarkHard": "Quitar marca de difícil" }, "search": { "open": "Buscar", @@ -809,6 +812,13 @@ "trainerProgressNewContent": "Contenido nuevo: {current}/{target}", "trainerProgressReview": "Repaso: {count}", "trainerProgressMixShare": "Parte mezclada: {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": "Este tipo de ejercicio todavía no se muestra de forma interactiva en la vista actual.", "unknownExerciseTypeLabel": "Tipo: {type}", "lessonReviewHeadlineDone": "Esta lección ya ha llegado a la fase de práctica libre.", diff --git a/frontend/src/i18n/locales/fr/socialnetwork.json b/frontend/src/i18n/locales/fr/socialnetwork.json index 1893377..245ed24 100644 --- a/frontend/src/i18n/locales/fr/socialnetwork.json +++ b/frontend/src/i18n/locales/fr/socialnetwork.json @@ -457,7 +457,10 @@ "askedVocab": "Demandé :", "stats": "statistiques", "success": "Succès", - "fail": "échec" + "fail": "échec", + "hardCount": "Marqués difficiles", + "markHard": "Marquer comme difficile", + "unmarkHard": "Retirer le marquage difficile" }, "search": { "open": "Recherche", @@ -809,6 +812,13 @@ "trainerProgressNewContent": "Nouveau contenu : {current}/{target}", "trainerProgressReview": "Répéter : {count}", "trainerProgressMixShare": "Proportion de mélange : {pourcentage} %", + "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": "Ce type d'exercice n'est pas encore affiché de manière interactive dans la vue actuelle.", "unknownExerciseTypeLabel": "Tapez : {type}", "lessonReviewHeadlineDone": "Cette leçon a atteint le niveau d'immersion libre.",