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">
|
<div v-else-if="pool.length === 0">
|
||||||
{{ $t('socialnetwork.vocab.practice.noPool') }}
|
{{ $t('socialnetwork.vocab.practice.noPool') }}
|
||||||
</div>
|
</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 v-else>
|
||||||
<div class="prompt">
|
<div class="prompt">
|
||||||
<div class="dir">{{ directionLabel }}</div>
|
<div class="dir">{{ directionLabel }}</div>
|
||||||
@@ -103,6 +107,18 @@
|
|||||||
<div class="right">
|
<div class="right">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="statTitle">{{ $t('socialnetwork.vocab.practice.stats') }}</div>
|
<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">
|
<div class="statRow">
|
||||||
<span class="k">{{ $t('socialnetwork.vocab.practice.success') }}</span>
|
<span class="k">{{ $t('socialnetwork.vocab.practice.success') }}</span>
|
||||||
<span class="v">{{ correctCount }} ({{ successPercent }}%)</span>
|
<span class="v">{{ correctCount }} ({{ successPercent }}%)</span>
|
||||||
@@ -122,6 +138,7 @@ import DialogWidget from '@/components/DialogWidget.vue';
|
|||||||
import apiClient from '@/utils/axios.js';
|
import apiClient from '@/utils/axios.js';
|
||||||
|
|
||||||
const PRACTICE_MIN_EXPOSURES = 3;
|
const PRACTICE_MIN_EXPOSURES = 3;
|
||||||
|
const SRS_SESSION_STORAGE_VERSION = 1;
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'VocabPracticeDialog',
|
name: 'VocabPracticeDialog',
|
||||||
@@ -137,6 +154,11 @@ export default {
|
|||||||
simpleMode: false,
|
simpleMode: false,
|
||||||
pool: [],
|
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
|
// session stats
|
||||||
correctCount: 0,
|
correctCount: 0,
|
||||||
wrongCount: 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: {
|
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 }) {
|
open({ languageId, chapterId, lessonId, courseId, initialPool = null, srsMode = false, onClose = null }) {
|
||||||
if (this.autoAdvanceTimer) {
|
if (this.autoAdvanceTimer) {
|
||||||
clearTimeout(this.autoAdvanceTimer);
|
clearTimeout(this.autoAdvanceTimer);
|
||||||
@@ -233,6 +348,8 @@ export default {
|
|||||||
this.initialPool = Array.isArray(initialPool) ? initialPool : null;
|
this.initialPool = Array.isArray(initialPool) ? initialPool : null;
|
||||||
this.allVocabs = false;
|
this.allVocabs = false;
|
||||||
this.simpleMode = false;
|
this.simpleMode = false;
|
||||||
|
this.srsSession = null;
|
||||||
|
this.srsQueueIds = [];
|
||||||
this.correctCount = 0;
|
this.correctCount = 0;
|
||||||
this.wrongCount = 0;
|
this.wrongCount = 0;
|
||||||
this.perId = {};
|
this.perId = {};
|
||||||
@@ -251,6 +368,7 @@ export default {
|
|||||||
clearTimeout(this.autoAdvanceTimer);
|
clearTimeout(this.autoAdvanceTimer);
|
||||||
this.autoAdvanceTimer = null;
|
this.autoAdvanceTimer = null;
|
||||||
}
|
}
|
||||||
|
this.saveSrsSession();
|
||||||
const cb = this.onClose;
|
const cb = this.onClose;
|
||||||
this.onClose = null;
|
this.onClose = null;
|
||||||
document.removeEventListener('keydown', this.handleKeyDown);
|
document.removeEventListener('keydown', this.handleKeyDown);
|
||||||
@@ -355,6 +473,9 @@ export default {
|
|||||||
if (this.initialPool) {
|
if (this.initialPool) {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
this.pool = this.normalizePool(this.initialPool);
|
this.pool = this.normalizePool(this.initialPool);
|
||||||
|
if (this.srsMode) {
|
||||||
|
this.initSrsSessionFromPool();
|
||||||
|
}
|
||||||
this.next();
|
this.next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -385,6 +506,9 @@ export default {
|
|||||||
this.pool = [];
|
this.pool = [];
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
|
if (this.srsMode) {
|
||||||
|
this.initSrsSessionFromPool();
|
||||||
|
}
|
||||||
this.next();
|
this.next();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -416,6 +540,11 @@ export default {
|
|||||||
pickNextItem() {
|
pickNextItem() {
|
||||||
const items = this.pool;
|
const items = this.pool;
|
||||||
if (!items || items.length === 0) return null;
|
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 recent = new Set(this.lastIds);
|
||||||
const underexposed = items
|
const underexposed = items
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
@@ -531,6 +660,23 @@ export default {
|
|||||||
}
|
}
|
||||||
this.locked = true;
|
this.locked = true;
|
||||||
await this.reportSrsReview(this.lastCorrect, rating);
|
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();
|
this.next();
|
||||||
},
|
},
|
||||||
submitChoice(opt) {
|
submitChoice(opt) {
|
||||||
@@ -573,6 +719,10 @@ export default {
|
|||||||
this.resetQuestion();
|
this.resetQuestion();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this.srsMode && Array.isArray(this.srsQueueIds) && this.srsQueueIds.length === 0) {
|
||||||
|
this.resetQuestion();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.resetQuestion();
|
this.resetQuestion();
|
||||||
this.current = this.pickNextItem();
|
this.current = this.pickNextItem();
|
||||||
if (!this.current) return;
|
if (!this.current) return;
|
||||||
@@ -646,6 +796,19 @@ export default {
|
|||||||
.controls {
|
.controls {
|
||||||
margin-top: 12px;
|
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 {
|
.srs-rating {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||||||
|
|||||||
@@ -134,6 +134,32 @@
|
|||||||
"random": "Random",
|
"random": "Random",
|
||||||
"submit": "Apil"
|
"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": {
|
"overview": {
|
||||||
"title": "Falukant - Overview",
|
"title": "Falukant - Overview",
|
||||||
"heroIntro": "Ang imong kahimtang sa ekonomiya, pamilya ug kabtangan sa usa ka mubo ug klarong overview.",
|
"heroIntro": "Ang imong kahimtang sa ekonomiya, pamilya ug kabtangan sa usa ka mubo ug klarong overview.",
|
||||||
|
|||||||
@@ -791,6 +791,9 @@
|
|||||||
"wrong": "Sayop.",
|
"wrong": "Sayop.",
|
||||||
"acceptable": "Acceptable answers:",
|
"acceptable": "Acceptable answers:",
|
||||||
"stats": "Stats",
|
"stats": "Stats",
|
||||||
|
"dueToday": "Due karon",
|
||||||
|
"done": "Nahuman",
|
||||||
|
"remaining": "Nahibilin",
|
||||||
"success": "Malampuson",
|
"success": "Malampuson",
|
||||||
"fail": "Fail",
|
"fail": "Fail",
|
||||||
"srsRateTitle": "Unsa ka lig-on sa imong pagbati?",
|
"srsRateTitle": "Unsa ka lig-on sa imong pagbati?",
|
||||||
@@ -801,7 +804,9 @@
|
|||||||
"srsGood": "Maayo",
|
"srsGood": "Maayo",
|
||||||
"srsGoodHint": "normal nga iskedyul",
|
"srsGoodHint": "normal nga iskedyul",
|
||||||
"srsEasy": "Sayon",
|
"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": {
|
"search": {
|
||||||
"open": "Pangita",
|
"open": "Pangita",
|
||||||
|
|||||||
@@ -452,6 +452,9 @@
|
|||||||
"wrong": "Falsch.",
|
"wrong": "Falsch.",
|
||||||
"acceptable": "Mögliche richtige Übersetzungen:",
|
"acceptable": "Mögliche richtige Übersetzungen:",
|
||||||
"stats": "Statistik",
|
"stats": "Statistik",
|
||||||
|
"dueToday": "Heute fällig",
|
||||||
|
"done": "Erledigt",
|
||||||
|
"remaining": "Noch offen",
|
||||||
"success": "Erfolg",
|
"success": "Erfolg",
|
||||||
"fail": "Misserfolg",
|
"fail": "Misserfolg",
|
||||||
"srsRateTitle": "Wie sicher war das?",
|
"srsRateTitle": "Wie sicher war das?",
|
||||||
@@ -462,7 +465,9 @@
|
|||||||
"srsGood": "Gut",
|
"srsGood": "Gut",
|
||||||
"srsGoodHint": "normal planen",
|
"srsGoodHint": "normal planen",
|
||||||
"srsEasy": "Leicht",
|
"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": {
|
"search": {
|
||||||
"open": "Suche",
|
"open": "Suche",
|
||||||
|
|||||||
@@ -124,6 +124,32 @@
|
|||||||
"children": "Children",
|
"children": "Children",
|
||||||
"children_unbaptised": "Unbaptised 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": {
|
"overview": {
|
||||||
"title": "Falukant - Overview",
|
"title": "Falukant - Overview",
|
||||||
"heroIntro": "Your status in economy, family and property in a condensed overview.",
|
"heroIntro": "Your status in economy, family and property in a condensed overview.",
|
||||||
|
|||||||
@@ -452,6 +452,9 @@
|
|||||||
"wrong": "Wrong.",
|
"wrong": "Wrong.",
|
||||||
"acceptable": "Acceptable answers:",
|
"acceptable": "Acceptable answers:",
|
||||||
"stats": "Stats",
|
"stats": "Stats",
|
||||||
|
"dueToday": "Due today",
|
||||||
|
"done": "Done",
|
||||||
|
"remaining": "Remaining",
|
||||||
"success": "Success",
|
"success": "Success",
|
||||||
"fail": "Fail",
|
"fail": "Fail",
|
||||||
"srsRateTitle": "How solid did it feel?",
|
"srsRateTitle": "How solid did it feel?",
|
||||||
@@ -462,7 +465,9 @@
|
|||||||
"srsGood": "Good",
|
"srsGood": "Good",
|
||||||
"srsGoodHint": "normal schedule",
|
"srsGoodHint": "normal schedule",
|
||||||
"srsEasy": "Easy",
|
"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": {
|
"search": {
|
||||||
"open": "Search",
|
"open": "Search",
|
||||||
|
|||||||
Reference in New Issue
Block a user