feat(VocabPracticeDialog): implement SRS session management and enhance UI feedback
All checks were successful
Deploy to production / deploy (push) Successful in 1m53s
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:
@@ -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));
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user