diff --git a/frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue b/frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue index e2e0a6b..de39619 100644 --- a/frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue +++ b/frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue @@ -28,6 +28,10 @@
{{ $t('socialnetwork.vocab.practice.noPool') }}
+
+
{{ $t('socialnetwork.vocab.practice.srsFinishedTitle') }}
+
{{ $t('socialnetwork.vocab.practice.srsFinishedDesc') }}
+
{{ directionLabel }}
@@ -103,6 +107,18 @@
{{ $t('socialnetwork.vocab.practice.stats') }}
+
+ {{ $t('socialnetwork.vocab.practice.dueToday') }} + {{ srsTotalDue }} +
+
+ {{ $t('socialnetwork.vocab.practice.done') }} + {{ srsDoneCount }} +
+
+ {{ $t('socialnetwork.vocab.practice.remaining') }} + {{ srsRemainingCount }} +
{{ $t('socialnetwork.vocab.practice.success') }} {{ correctCount }} ({{ successPercent }}%) @@ -122,6 +138,7 @@ import DialogWidget from '@/components/DialogWidget.vue'; import apiClient from '@/utils/axios.js'; const PRACTICE_MIN_EXPOSURES = 3; +const SRS_SESSION_STORAGE_VERSION = 1; export default { name: 'VocabPracticeDialog', @@ -137,6 +154,11 @@ export default { simpleMode: false, pool: [], + // SRS session (Langzeitgedächtnis) progress & persistence + // Stored per day and course so the user can close/reopen without starting over. + srsSession: null, // { version, dateKey, courseId, initialTotalDue, initialDueIds, doneIds, correctCount, wrongCount } + srsQueueIds: [], // remaining due ids for this session, in due order + // session stats correctCount: 0, wrongCount: 0, @@ -220,8 +242,101 @@ export default { } ]; }, + srsTotalDue() { + return Number(this.srsSession?.initialTotalDue || 0) || 0; + }, + srsDoneCount() { + const done = Array.isArray(this.srsSession?.doneIds) ? this.srsSession.doneIds.length : 0; + return Math.min(done, this.srsTotalDue || 0); + }, + srsRemainingCount() { + const total = this.srsTotalDue || 0; + return Math.max(0, total - this.srsDoneCount); + }, }, methods: { + getLocalDateKey() { + const d = new Date(); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; + }, + srsStorageKey() { + const courseId = this.openParams?.courseId; + if (!courseId) return null; + return `yourpart:vocab:srsSession:v${SRS_SESSION_STORAGE_VERSION}:${courseId}:${this.getLocalDateKey()}`; + }, + loadSrsSession() { + const key = this.srsStorageKey(); + if (!key) return null; + try { + const raw = localStorage.getItem(key); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (!parsed || parsed.version !== SRS_SESSION_STORAGE_VERSION) return null; + if (parsed.courseId !== this.openParams?.courseId) return null; + if (parsed.dateKey !== this.getLocalDateKey()) return null; + return parsed; + } catch (_) { + return null; + } + }, + saveSrsSession() { + if (!this.srsMode || !this.openParams?.courseId) return; + const key = this.srsStorageKey(); + if (!key) return; + try { + const session = this.srsSession || null; + if (!session) return; + session.correctCount = this.correctCount; + session.wrongCount = this.wrongCount; + localStorage.setItem(key, JSON.stringify(session)); + } catch (_) { + // ignore storage issues (private mode, quota, etc.) + } + }, + initSrsSessionFromPool() { + if (!this.srsMode || !this.openParams?.courseId) { + this.srsSession = null; + this.srsQueueIds = []; + return; + } + const dueIds = (this.pool || []).map((it) => it.id); + const stored = this.loadSrsSession(); + + // If the previous session is already complete, start fresh (new batch of due items). + if (stored && Number(stored.initialTotalDue || 0) > 0 && Array.isArray(stored.doneIds) && stored.doneIds.length >= stored.initialTotalDue) { + this.srsSession = null; + } else if (stored) { + this.srsSession = stored; + } else { + this.srsSession = null; + } + + if (!this.srsSession) { + this.srsSession = { + version: SRS_SESSION_STORAGE_VERSION, + dateKey: this.getLocalDateKey(), + courseId: this.openParams.courseId, + initialTotalDue: dueIds.length, + initialDueIds: dueIds, + doneIds: [], + correctCount: 0, + wrongCount: 0, + }; + this.correctCount = 0; + this.wrongCount = 0; + } else { + // Resume: keep initialTotalDue stable (what the user started with), and prune queue to the currently due ids. + this.correctCount = Number(this.srsSession.correctCount || 0) || 0; + this.wrongCount = Number(this.srsSession.wrongCount || 0) || 0; + } + + const doneSet = new Set(Array.isArray(this.srsSession.doneIds) ? this.srsSession.doneIds : []); + this.srsQueueIds = dueIds.filter((id) => !doneSet.has(id)); + this.saveSrsSession(); + }, open({ languageId, chapterId, lessonId, courseId, initialPool = null, srsMode = false, onClose = null }) { if (this.autoAdvanceTimer) { clearTimeout(this.autoAdvanceTimer); @@ -233,6 +348,8 @@ export default { this.initialPool = Array.isArray(initialPool) ? initialPool : null; this.allVocabs = false; this.simpleMode = false; + this.srsSession = null; + this.srsQueueIds = []; this.correctCount = 0; this.wrongCount = 0; this.perId = {}; @@ -251,6 +368,7 @@ export default { clearTimeout(this.autoAdvanceTimer); this.autoAdvanceTimer = null; } + this.saveSrsSession(); const cb = this.onClose; this.onClose = null; document.removeEventListener('keydown', this.handleKeyDown); @@ -355,6 +473,9 @@ export default { if (this.initialPool) { this.loading = false; this.pool = this.normalizePool(this.initialPool); + if (this.srsMode) { + this.initSrsSessionFromPool(); + } this.next(); return; } @@ -385,6 +506,9 @@ export default { this.pool = []; } finally { this.loading = false; + if (this.srsMode) { + this.initSrsSessionFromPool(); + } this.next(); } }, @@ -416,6 +540,11 @@ export default { pickNextItem() { const items = this.pool; if (!items || items.length === 0) return null; + if (this.srsMode) { + const nextId = Array.isArray(this.srsQueueIds) ? this.srsQueueIds[0] : null; + if (!nextId) return null; + return items.find((it) => it.id === nextId) || null; + } const recent = new Set(this.lastIds); const underexposed = items .map((item) => { @@ -531,6 +660,23 @@ export default { } this.locked = true; await this.reportSrsReview(this.lastCorrect, rating); + + // Mark current due item as completed for this session and persist. + const id = this.current?.id; + if (id && this.srsSession) { + const done = Array.isArray(this.srsSession.doneIds) ? this.srsSession.doneIds : []; + if (!done.includes(id)) { + done.push(id); + this.srsSession.doneIds = done; + } + if (Array.isArray(this.srsQueueIds) && this.srsQueueIds[0] === id) { + this.srsQueueIds = this.srsQueueIds.slice(1); + } else if (Array.isArray(this.srsQueueIds)) { + this.srsQueueIds = this.srsQueueIds.filter((x) => x !== id); + } + this.saveSrsSession(); + } + this.next(); }, submitChoice(opt) { @@ -573,6 +719,10 @@ export default { this.resetQuestion(); return; } + if (this.srsMode && Array.isArray(this.srsQueueIds) && this.srsQueueIds.length === 0) { + this.resetQuestion(); + return; + } this.resetQuestion(); this.current = this.pickNextItem(); if (!this.current) return; @@ -646,6 +796,19 @@ export default { .controls { margin-top: 12px; } +.srs-finished { + padding: 14px; + background: #fff; + border: 1px solid #ccc; +} +.srs-finished__title { + font-size: 1.1em; + font-weight: 700; + margin-bottom: 6px; +} +.srs-finished__desc { + color: #555; +} .srs-rating { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); diff --git a/frontend/src/i18n/locales/ceb/falukant.json b/frontend/src/i18n/locales/ceb/falukant.json index a3d97aa..8675b33 100644 --- a/frontend/src/i18n/locales/ceb/falukant.json +++ b/frontend/src/i18n/locales/ceb/falukant.json @@ -134,6 +134,32 @@ "random": "Random", "submit": "Apil" }, + "genderAge": { + "ageGroups": "infant:2|toddler:4|child:12|teen:18|youngAdult:25|adult:50|mature:70|elder:999", + "neutral": { + "child": "Bata" + }, + "male": { + "infant": "Masuso nga lalaki", + "toddler": "Gamay nga batang lalaki", + "child": "Batang lalaki", + "teen": "Batan-on nga lalaki", + "youngAdult": "Batan-on nga lalaki", + "adult": "Lalaki", + "mature": "Tigulang nga lalaki", + "elder": "Katigulangan nga lalaki" + }, + "female": { + "infant": "Masuso nga babaye", + "toddler": "Gamay nga batang babaye", + "child": "Batang babaye", + "teen": "Batan-on nga babaye", + "youngAdult": "Batan-on nga babaye", + "adult": "Babaye", + "mature": "Tigulang nga babaye", + "elder": "Katigulangan nga babaye" + } + }, "overview": { "title": "Falukant - Overview", "heroIntro": "Ang imong kahimtang sa ekonomiya, pamilya ug kabtangan sa usa ka mubo ug klarong overview.", diff --git a/frontend/src/i18n/locales/ceb/socialnetwork.json b/frontend/src/i18n/locales/ceb/socialnetwork.json index 95831b3..2f28a5d 100644 --- a/frontend/src/i18n/locales/ceb/socialnetwork.json +++ b/frontend/src/i18n/locales/ceb/socialnetwork.json @@ -791,6 +791,9 @@ "wrong": "Sayop.", "acceptable": "Acceptable answers:", "stats": "Stats", + "dueToday": "Due karon", + "done": "Nahuman", + "remaining": "Nahibilin", "success": "Malampuson", "fail": "Fail", "srsRateTitle": "Unsa ka lig-on sa imong pagbati?", @@ -801,7 +804,9 @@ "srsGood": "Maayo", "srsGoodHint": "normal nga iskedyul", "srsEasy": "Sayon", - "srsEasyHint": "mas layo nga interval" + "srsEasyHint": "mas layo nga interval", + "srsFinishedTitle": "Nahuman na karon", + "srsFinishedDesc": "Napratik na nimo ang tanang due items sa kini nga round. Kung naay bag-ong due items unya, makapadayon ka dinhi." }, "search": { "open": "Pangita", diff --git a/frontend/src/i18n/locales/de/socialnetwork.json b/frontend/src/i18n/locales/de/socialnetwork.json index 7a7d233..51e5756 100644 --- a/frontend/src/i18n/locales/de/socialnetwork.json +++ b/frontend/src/i18n/locales/de/socialnetwork.json @@ -452,6 +452,9 @@ "wrong": "Falsch.", "acceptable": "Mögliche richtige Übersetzungen:", "stats": "Statistik", + "dueToday": "Heute fällig", + "done": "Erledigt", + "remaining": "Noch offen", "success": "Erfolg", "fail": "Misserfolg", "srsRateTitle": "Wie sicher war das?", @@ -462,7 +465,9 @@ "srsGood": "Gut", "srsGoodHint": "normal planen", "srsEasy": "Leicht", - "srsEasyHint": "größerer Abstand" + "srsEasyHint": "größerer Abstand", + "srsFinishedTitle": "Für heute erledigt", + "srsFinishedDesc": "Du hast alle fälligen Begriffe aus dieser Runde geübt. Wenn später neue Begriffe fällig werden, kannst du hier weitermachen." }, "search": { "open": "Suche", diff --git a/frontend/src/i18n/locales/en/falukant.json b/frontend/src/i18n/locales/en/falukant.json index f342b17..f6a2552 100644 --- a/frontend/src/i18n/locales/en/falukant.json +++ b/frontend/src/i18n/locales/en/falukant.json @@ -124,6 +124,32 @@ "children": "Children", "children_unbaptised": "Unbaptised children" }, + "genderAge": { + "ageGroups": "infant:2|toddler:4|child:12|teen:18|youngAdult:25|adult:50|mature:70|elder:999", + "neutral": { + "child": "Child" + }, + "male": { + "infant": "Baby boy", + "toddler": "Toddler", + "child": "Boy", + "teen": "Young man", + "youngAdult": "Young man", + "adult": "Man", + "mature": "Older man", + "elder": "Elder" + }, + "female": { + "infant": "Baby girl", + "toddler": "Toddler", + "child": "Girl", + "teen": "Young woman", + "youngAdult": "Young woman", + "adult": "Woman", + "mature": "Older woman", + "elder": "Elder" + } + }, "overview": { "title": "Falukant - Overview", "heroIntro": "Your status in economy, family and property in a condensed overview.", diff --git a/frontend/src/i18n/locales/en/socialnetwork.json b/frontend/src/i18n/locales/en/socialnetwork.json index 7e30241..178b017 100644 --- a/frontend/src/i18n/locales/en/socialnetwork.json +++ b/frontend/src/i18n/locales/en/socialnetwork.json @@ -452,6 +452,9 @@ "wrong": "Wrong.", "acceptable": "Acceptable answers:", "stats": "Stats", + "dueToday": "Due today", + "done": "Done", + "remaining": "Remaining", "success": "Success", "fail": "Fail", "srsRateTitle": "How solid did it feel?", @@ -462,7 +465,9 @@ "srsGood": "Good", "srsGoodHint": "normal schedule", "srsEasy": "Easy", - "srsEasyHint": "longer interval" + "srsEasyHint": "longer interval", + "srsFinishedTitle": "Done for today", + "srsFinishedDesc": "You have practiced all due items from this round. When new items become due later, you can continue here." }, "search": { "open": "Search",