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