feat(VocabPracticeDialog): implement SRS session management and enhance UI feedback
All checks were successful
Deploy to production / deploy (push) Successful in 1m53s

- Added support for spaced repetition system (SRS) session management, allowing users to resume practice sessions with due items.
- Introduced new UI elements to display SRS session status, including total due, completed, and remaining items.
- Enhanced localization for SRS-related messages in multiple languages, improving user experience and clarity.
- Updated methods for saving and loading SRS session data to local storage, ensuring persistence across sessions.
This commit is contained in:
Torsten Schulz (local)
2026-04-17 16:28:19 +02:00
parent 9c121d2dc2
commit 3232e42251
6 changed files with 233 additions and 3 deletions

View File

@@ -28,6 +28,10 @@
<div v-else-if="pool.length === 0">
{{ $t('socialnetwork.vocab.practice.noPool') }}
</div>
<div v-else-if="srsMode && srsQueueIds.length === 0" class="srs-finished">
<div class="srs-finished__title">{{ $t('socialnetwork.vocab.practice.srsFinishedTitle') }}</div>
<div class="srs-finished__desc">{{ $t('socialnetwork.vocab.practice.srsFinishedDesc') }}</div>
</div>
<div v-else>
<div class="prompt">
<div class="dir">{{ directionLabel }}</div>
@@ -103,6 +107,18 @@
<div class="right">
<div class="stat">
<div class="statTitle">{{ $t('socialnetwork.vocab.practice.stats') }}</div>
<div v-if="srsMode" class="statRow">
<span class="k">{{ $t('socialnetwork.vocab.practice.dueToday') }}</span>
<span class="v">{{ srsTotalDue }}</span>
</div>
<div v-if="srsMode" class="statRow">
<span class="k">{{ $t('socialnetwork.vocab.practice.done') }}</span>
<span class="v">{{ srsDoneCount }}</span>
</div>
<div v-if="srsMode" class="statRow">
<span class="k">{{ $t('socialnetwork.vocab.practice.remaining') }}</span>
<span class="v">{{ srsRemainingCount }}</span>
</div>
<div class="statRow">
<span class="k">{{ $t('socialnetwork.vocab.practice.success') }}</span>
<span class="v">{{ correctCount }} ({{ successPercent }}%)</span>
@@ -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));

View File

@@ -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.",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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.",

View File

@@ -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",