feat(vocab): add hard status management for SRS items and update related logic
This commit is contained in:
@@ -43,10 +43,16 @@ class VocabController {
|
|||||||
this.getCourseSrsDue = this._wrapWithUser((userId, req) =>
|
this.getCourseSrsDue = this._wrapWithUser((userId, req) =>
|
||||||
this.service.getCourseSrsDue(userId, req.params.courseId, req.query)
|
this.service.getCourseSrsDue(userId, req.params.courseId, req.query)
|
||||||
);
|
);
|
||||||
|
this.getCourseHardSrsItems = this._wrapWithUser((userId, req) =>
|
||||||
|
this.service.getCourseHardSrsItems(userId, req.params.courseId)
|
||||||
|
);
|
||||||
this.reviewSrsItem = this._wrapWithUser((userId, req) =>
|
this.reviewSrsItem = this._wrapWithUser((userId, req) =>
|
||||||
this.service.reviewSrsItem(userId, req.body),
|
this.service.reviewSrsItem(userId, req.body),
|
||||||
{ successStatus: 201 }
|
{ successStatus: 201 }
|
||||||
);
|
);
|
||||||
|
this.clearSrsItemHardStatus = this._wrapWithUser((userId, req) =>
|
||||||
|
this.service.clearSrsItemHardStatus(userId, req.body)
|
||||||
|
);
|
||||||
this.getCourseByShareCode = this._wrapWithUser((userId, req) => this.service.getCourseByShareCode(userId, req.body.shareCode));
|
this.getCourseByShareCode = this._wrapWithUser((userId, req) => this.service.getCourseByShareCode(userId, req.body.shareCode));
|
||||||
this.updateCourse = this._wrapWithUser((userId, req) => this.service.updateCourse(userId, req.params.courseId, req.body));
|
this.updateCourse = this._wrapWithUser((userId, req) => this.service.updateCourse(userId, req.params.courseId, req.body));
|
||||||
this.deleteCourse = this._wrapWithUser((userId, req) => this.service.deleteCourse(userId, req.params.courseId));
|
this.deleteCourse = this._wrapWithUser((userId, req) => this.service.deleteCourse(userId, req.params.courseId));
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface) {
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE community.vocab_srs_item
|
||||||
|
ADD COLUMN IF NOT EXISTS is_hard BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
`);
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vocab_srs_item_hard
|
||||||
|
ON community.vocab_srs_item (user_id, course_id, is_hard, next_due_at);
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
DROP INDEX IF EXISTS community.idx_vocab_srs_item_hard;
|
||||||
|
`);
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE community.vocab_srs_item
|
||||||
|
DROP COLUMN IF EXISTS is_hard;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -81,6 +81,12 @@ VocabSrsItem.init({
|
|||||||
allowNull: false,
|
allowNull: false,
|
||||||
defaultValue: 0,
|
defaultValue: 0,
|
||||||
field: 'lapse_count'
|
field: 'lapse_count'
|
||||||
|
},
|
||||||
|
isHard: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false,
|
||||||
|
field: 'is_hard'
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
sequelize,
|
sequelize,
|
||||||
|
|||||||
@@ -36,10 +36,12 @@ router.get('/courses/:courseId/completed-lesson-vocabs', vocabController.getComp
|
|||||||
router.get('/courses/:courseId/dictionary', vocabController.getCourseDictionary);
|
router.get('/courses/:courseId/dictionary', vocabController.getCourseDictionary);
|
||||||
router.get('/courses/:courseId/distractor-pool', vocabController.getVocabDistractorPool);
|
router.get('/courses/:courseId/distractor-pool', vocabController.getVocabDistractorPool);
|
||||||
router.get('/courses/:courseId/srs/due', vocabController.getCourseSrsDue);
|
router.get('/courses/:courseId/srs/due', vocabController.getCourseSrsDue);
|
||||||
|
router.get('/courses/:courseId/srs/hard', vocabController.getCourseHardSrsItems);
|
||||||
router.get('/courses/:courseId', vocabController.getCourse);
|
router.get('/courses/:courseId', vocabController.getCourse);
|
||||||
router.put('/courses/:courseId', vocabController.updateCourse);
|
router.put('/courses/:courseId', vocabController.updateCourse);
|
||||||
router.delete('/courses/:courseId', vocabController.deleteCourse);
|
router.delete('/courses/:courseId', vocabController.deleteCourse);
|
||||||
router.post('/srs/review', vocabController.reviewSrsItem);
|
router.post('/srs/review', vocabController.reviewSrsItem);
|
||||||
|
router.patch('/srs/hard', vocabController.clearSrsItemHardStatus);
|
||||||
|
|
||||||
// Lessons
|
// Lessons
|
||||||
router.post('/courses/:courseId/lessons', vocabController.addLessonToCourse);
|
router.post('/courses/:courseId/lessons', vocabController.addLessonToCourse);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { notifyUser } from '../utils/socket.js';
|
|||||||
import { Op } from 'sequelize';
|
import { Op } from 'sequelize';
|
||||||
import { BISAYA_PHASE1_DIDACTICS, BISAYA_DIDACTICS_FRAGMENTS } from '../scripts/bisaya-course-phase1.js';
|
import { BISAYA_PHASE1_DIDACTICS, BISAYA_DIDACTICS_FRAGMENTS } from '../scripts/bisaya-course-phase1.js';
|
||||||
|
|
||||||
|
const DAILY_SRS_LIMIT = 50;
|
||||||
|
|
||||||
export default class VocabService {
|
export default class VocabService {
|
||||||
_stripGermanNumberSeparators(value) {
|
_stripGermanNumberSeparators(value) {
|
||||||
return String(value || '').replace(/[\s.-]+/g, '');
|
return String(value || '').replace(/[\s.-]+/g, '');
|
||||||
@@ -202,35 +204,23 @@ export default class VocabService {
|
|||||||
return (leftLooksShortFragment && rightLooksSentence) || (rightLooksShortFragment && leftLooksSentence);
|
return (leftLooksShortFragment && rightLooksSentence) || (rightLooksShortFragment && leftLooksSentence);
|
||||||
}
|
}
|
||||||
|
|
||||||
_calculateSrsSchedule(item, { correct, rating = null } = {}) {
|
_calculateSrsSchedule(item, { correct } = {}) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const previousStage = Math.max(0, Number(item?.stage) || 0);
|
if (!correct) {
|
||||||
const previousInterval = Math.max(0, Number(item?.intervalDays) || 0);
|
|
||||||
const normalizedRating = String(rating || '').toLowerCase();
|
|
||||||
const isCorrect = Boolean(correct) && normalizedRating !== 'again';
|
|
||||||
if (!isCorrect) {
|
|
||||||
return {
|
return {
|
||||||
stage: Math.max(0, previousStage - 1),
|
stage: 0,
|
||||||
intervalDays: 0,
|
intervalDays: 0,
|
||||||
nextDueAt: new Date(now.getTime() + 10 * 60 * 1000),
|
nextDueAt: new Date(now.getTime() + 10 * 60 * 1000),
|
||||||
lapseDelta: 1
|
lapseDelta: 1
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Neue einfache Policy:
|
// Intervals are based only on successful answers in the daily SRS review.
|
||||||
// - 'easy' -> 7 Tage
|
// There is deliberately no user-selected difficulty rating: each successful
|
||||||
// - 'good'/'normal' -> 4 Tage
|
// recall moves the term to the next, less frequent interval.
|
||||||
// - 'hard' -> 1 Tag
|
const intervals = [1, 3, 7, 14, 30, 60, 120];
|
||||||
// Außerdem: nextDueAt darf nicht mehr am gleichen Kalendertag liegen.
|
const previousCorrectCount = Math.max(0, Number(item?.correctCount) || 0);
|
||||||
let intervalDays;
|
const intervalDays = intervals[Math.min(previousCorrectCount, intervals.length - 1)];
|
||||||
if (normalizedRating === 'easy') {
|
|
||||||
intervalDays = 7;
|
|
||||||
} else if (normalizedRating === 'hard') {
|
|
||||||
intervalDays = 1;
|
|
||||||
} else {
|
|
||||||
// default / 'good' / unspecified
|
|
||||||
intervalDays = 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bestimme nextDueAt als Start des Tages (00:00) nach intervalDays
|
// Bestimme nextDueAt als Start des Tages (00:00) nach intervalDays
|
||||||
const nextDueAt = new Date(now);
|
const nextDueAt = new Date(now);
|
||||||
@@ -239,8 +229,7 @@ export default class VocabService {
|
|||||||
// Gehe vorwärts: morgen + (intervalDays - 1)
|
// Gehe vorwärts: morgen + (intervalDays - 1)
|
||||||
nextDueAt.setDate(nextDueAt.getDate() + 1 + Math.max(0, intervalDays - 1));
|
nextDueAt.setDate(nextDueAt.getDate() + 1 + Math.max(0, intervalDays - 1));
|
||||||
|
|
||||||
// Stage-Logik: einfache Fortschrittsstufe basierend auf intervalDays
|
const nextStage = Math.min(intervals.length, previousCorrectCount + 1);
|
||||||
let nextStage = Math.min(8, Math.max(0, Math.floor(Math.log2(intervalDays + 1))));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stage: nextStage,
|
stage: nextStage,
|
||||||
@@ -2012,7 +2001,10 @@ export default class VocabService {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const limit = this._clampInteger(query?.limit, { min: 1, max: 100, fallback: 30 });
|
// A daily SRS batch is intentionally capped. Excess due items remain due
|
||||||
|
// and are shown on the following day rather than silently overloading today.
|
||||||
|
const requestedLimit = this._clampInteger(query?.limit, { min: 1, max: DAILY_SRS_LIMIT, fallback: DAILY_SRS_LIMIT });
|
||||||
|
const limit = Math.min(DAILY_SRS_LIMIT, requestedLimit);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const dueWhere = {
|
const dueWhere = {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -2052,6 +2044,7 @@ export default class VocabService {
|
|||||||
dueAt: now.toISOString(),
|
dueAt: now.toISOString(),
|
||||||
count: rows.length,
|
count: rows.length,
|
||||||
totalDueCount,
|
totalDueCount,
|
||||||
|
dailyLimit: DAILY_SRS_LIMIT,
|
||||||
limit,
|
limit,
|
||||||
items: rows.map((item) => ({
|
items: rows.map((item) => ({
|
||||||
itemKey: item.itemKey,
|
itemKey: item.itemKey,
|
||||||
@@ -2066,7 +2059,42 @@ export default class VocabService {
|
|||||||
nextDueAt: this._normalizeIsoDate(item.nextDueAt),
|
nextDueAt: this._normalizeIsoDate(item.nextDueAt),
|
||||||
correctCount: item.correctCount,
|
correctCount: item.correctCount,
|
||||||
wrongCount: item.wrongCount,
|
wrongCount: item.wrongCount,
|
||||||
lapseCount: item.lapseCount
|
lapseCount: item.lapseCount,
|
||||||
|
isHard: item.isHard
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCourseHardSrsItems(hashedUserId, courseId) {
|
||||||
|
const user = await this._getUserByHashedId(hashedUserId);
|
||||||
|
const course = await VocabCourse.findByPk(courseId);
|
||||||
|
if (!course) {
|
||||||
|
const err = new Error('Course not found');
|
||||||
|
err.status = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (course.ownerUserId !== user.id && !course.isPublic) {
|
||||||
|
const err = new Error('Access denied');
|
||||||
|
err.status = 403;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await VocabSrsItem.findAll({
|
||||||
|
where: { userId: user.id, courseId: Number(course.id), isHard: true },
|
||||||
|
order: [['nextDueAt', 'ASC'], ['wrongCount', 'DESC']]
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
courseId: course.id,
|
||||||
|
items: items.filter((item) => this._isTrainableSrsPair(item)).map((item) => ({
|
||||||
|
itemKey: item.itemKey,
|
||||||
|
courseId: item.courseId,
|
||||||
|
lessonId: item.lessonId,
|
||||||
|
learning: item.learning,
|
||||||
|
reference: item.reference,
|
||||||
|
direction: item.direction,
|
||||||
|
correctCount: item.correctCount,
|
||||||
|
wrongCount: item.wrongCount,
|
||||||
|
isHard: true
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2143,8 +2171,7 @@ export default class VocabService {
|
|||||||
|
|
||||||
const correct = Boolean(payload?.correct);
|
const correct = Boolean(payload?.correct);
|
||||||
const schedule = this._calculateSrsSchedule(item, {
|
const schedule = this._calculateSrsSchedule(item, {
|
||||||
correct,
|
correct
|
||||||
rating: payload?.rating
|
|
||||||
});
|
});
|
||||||
|
|
||||||
item.stage = schedule.stage;
|
item.stage = schedule.stage;
|
||||||
@@ -2156,6 +2183,7 @@ export default class VocabService {
|
|||||||
} else {
|
} else {
|
||||||
item.wrongCount += 1;
|
item.wrongCount += 1;
|
||||||
item.lapseCount += schedule.lapseDelta;
|
item.lapseCount += schedule.lapseDelta;
|
||||||
|
item.isHard = true;
|
||||||
}
|
}
|
||||||
await item.save();
|
await item.save();
|
||||||
|
|
||||||
@@ -2180,10 +2208,31 @@ export default class VocabService {
|
|||||||
nextDueAt: this._normalizeIsoDate(item.nextDueAt),
|
nextDueAt: this._normalizeIsoDate(item.nextDueAt),
|
||||||
correctCount: item.correctCount,
|
correctCount: item.correctCount,
|
||||||
wrongCount: item.wrongCount,
|
wrongCount: item.wrongCount,
|
||||||
lapseCount: item.lapseCount
|
lapseCount: item.lapseCount,
|
||||||
|
isHard: item.isHard
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async clearSrsItemHardStatus(hashedUserId, payload = {}) {
|
||||||
|
const user = await this._getUserByHashedId(hashedUserId);
|
||||||
|
const courseId = this._clampInteger(payload?.courseId, { min: 1, max: 1_000_000, fallback: 0 });
|
||||||
|
const itemKey = this._sanitizeShortString(payload?.itemKey, 80);
|
||||||
|
if (!courseId || !itemKey) {
|
||||||
|
const err = new Error('Missing SRS item');
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const item = await VocabSrsItem.findOne({ where: { userId: user.id, courseId, itemKey } });
|
||||||
|
if (!item) {
|
||||||
|
const err = new Error('SRS item not found');
|
||||||
|
err.status = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
item.isHard = false;
|
||||||
|
await item.save();
|
||||||
|
return { itemKey: item.itemKey, isHard: false };
|
||||||
|
}
|
||||||
|
|
||||||
async searchVocabs(hashedUserId, languageId, { q = '', learning = '', motherTongue = '' } = {}) {
|
async searchVocabs(hashedUserId, languageId, { q = '', learning = '', motherTongue = '' } = {}) {
|
||||||
const user = await this._getUserByHashedId(hashedUserId);
|
const user = await this._getUserByHashedId(hashedUserId);
|
||||||
const access = await this._getLanguageAccess(user.id, languageId);
|
const access = await this._getLanguageAccess(user.id, languageId);
|
||||||
|
|||||||
@@ -45,22 +45,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showSrsRatingButtons" class="srs-rating">
|
|
||||||
<div class="srs-rating__title">{{ $t('socialnetwork.vocab.practice.srsRateTitle') }}</div>
|
|
||||||
<button
|
|
||||||
v-for="option in srsRatingOptions"
|
|
||||||
:key="option.value"
|
|
||||||
type="button"
|
|
||||||
class="srs-rating__button"
|
|
||||||
:class="`srs-rating__button--${option.value}`"
|
|
||||||
:disabled="locked"
|
|
||||||
@click="submitSrsRating(option.value)"
|
|
||||||
>
|
|
||||||
<strong>{{ option.label }}</strong>
|
|
||||||
<span>{{ option.hint }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!answered" class="answerArea">
|
<div v-if="!answered" class="answerArea">
|
||||||
<div v-if="simpleMode" class="choices">
|
<div v-if="simpleMode" class="choices">
|
||||||
<button
|
<button
|
||||||
@@ -94,12 +78,6 @@
|
|||||||
<button v-else-if="showSkipButton" @click="skip">
|
<button v-else-if="showSkipButton" @click="skip">
|
||||||
{{ $t('socialnetwork.vocab.practice.skip') }}
|
{{ $t('socialnetwork.vocab.practice.skip') }}
|
||||||
</button>
|
</button>
|
||||||
<button v-if="current && !isCurrentMarkedHard" @click="markCurrentAsHard">
|
|
||||||
{{ $t('socialnetwork.vocab.practice.markHard') }}
|
|
||||||
</button>
|
|
||||||
<button v-else-if="current && isCurrentMarkedHard" @click="unmarkCurrentAsHard">
|
|
||||||
{{ $t('socialnetwork.vocab.practice.unmarkHard') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="lastWrongReview" class="solution-card solution-card--persistent">
|
<div v-if="lastWrongReview" class="solution-card solution-card--persistent">
|
||||||
@@ -162,7 +140,6 @@ const PRACTICE_MIN_EXPOSURES = 3;
|
|||||||
const SRS_SESSION_STORAGE_VERSION = 2;
|
const SRS_SESSION_STORAGE_VERSION = 2;
|
||||||
const HARD_REQUIRED_CONSECUTIVE_CORRECT = 5;
|
const HARD_REQUIRED_CONSECUTIVE_CORRECT = 5;
|
||||||
const MAX_DAILY_DUE = 50;
|
const MAX_DAILY_DUE = 50;
|
||||||
const MAX_DAILY_HARD_VOCABS = 7;
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'VocabPracticeDialog',
|
name: 'VocabPracticeDialog',
|
||||||
@@ -171,7 +148,6 @@ export default {
|
|||||||
return {
|
return {
|
||||||
openParams: null, // { languageId, chapterId, lessonId, courseId }
|
openParams: null, // { languageId, chapterId, lessonId, courseId }
|
||||||
onClose: null,
|
onClose: null,
|
||||||
onDailyHardLimitReached: null,
|
|
||||||
loading: false,
|
loading: false,
|
||||||
allVocabs: false,
|
allVocabs: false,
|
||||||
srsMode: false,
|
srsMode: false,
|
||||||
@@ -258,9 +234,6 @@ export default {
|
|||||||
showSkipButton() {
|
showSkipButton() {
|
||||||
return !this.answered;
|
return !this.answered;
|
||||||
},
|
},
|
||||||
showSrsRatingButtons() {
|
|
||||||
return this.srsMode && this.answered && !this.locked;
|
|
||||||
},
|
|
||||||
visibleCorrectAnswers() {
|
visibleCorrectAnswers() {
|
||||||
const answers = Array.isArray(this.acceptableAnswers) ? this.acceptableAnswers.filter(Boolean) : [];
|
const answers = Array.isArray(this.acceptableAnswers) ? this.acceptableAnswers.filter(Boolean) : [];
|
||||||
if (answers.length > 0) {
|
if (answers.length > 0) {
|
||||||
@@ -272,37 +245,6 @@ export default {
|
|||||||
const fallback = this.direction === 'L2R' ? this.current.reference : this.current.learning;
|
const fallback = this.direction === 'L2R' ? this.current.reference : this.current.learning;
|
||||||
return this.expandAnswerVariants(fallback);
|
return this.expandAnswerVariants(fallback);
|
||||||
},
|
},
|
||||||
srsRatingOptions() {
|
|
||||||
if (!this.answered) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
if (!this.lastCorrect) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
value: 'again',
|
|
||||||
label: this.$t('socialnetwork.vocab.practice.srsAgain'),
|
|
||||||
hint: this.$t('socialnetwork.vocab.practice.srsAgainHint')
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
value: 'hard',
|
|
||||||
label: this.$t('socialnetwork.vocab.practice.srsHard'),
|
|
||||||
hint: this.$t('socialnetwork.vocab.practice.srsHardHint')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'good',
|
|
||||||
label: this.$t('socialnetwork.vocab.practice.srsGood'),
|
|
||||||
hint: this.$t('socialnetwork.vocab.practice.srsGoodHint')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'easy',
|
|
||||||
label: this.$t('socialnetwork.vocab.practice.srsEasy'),
|
|
||||||
hint: this.$t('socialnetwork.vocab.practice.srsEasyHint')
|
|
||||||
}
|
|
||||||
];
|
|
||||||
},
|
|
||||||
srsTotalDue() {
|
srsTotalDue() {
|
||||||
return Number(this.srsSession?.initialTotalDue || 0) || 0;
|
return Number(this.srsSession?.initialTotalDue || 0) || 0;
|
||||||
},
|
},
|
||||||
@@ -446,21 +388,6 @@ export default {
|
|||||||
this.hardMasteryByKey[key] = 0;
|
this.hardMasteryByKey[key] = 0;
|
||||||
}
|
}
|
||||||
this.saveHardVocabMap();
|
this.saveHardVocabMap();
|
||||||
if (this.srsMode) {
|
|
||||||
this.maybeStartDailyHardPractice();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
maybeStartDailyHardPractice() {
|
|
||||||
if (this.hardCount < MAX_DAILY_HARD_VOCABS) return;
|
|
||||||
const callback = this.onDailyHardLimitReached;
|
|
||||||
this.close();
|
|
||||||
this.$nextTick(() => {
|
|
||||||
try {
|
|
||||||
callback?.();
|
|
||||||
} catch (_) {
|
|
||||||
// A missing parent callback must not interrupt the dialog close.
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
unmarkCurrentAsHard() {
|
unmarkCurrentAsHard() {
|
||||||
if (!this.current) return;
|
if (!this.current) return;
|
||||||
@@ -604,7 +531,7 @@ export default {
|
|||||||
}
|
}
|
||||||
this.saveSrsSession();
|
this.saveSrsSession();
|
||||||
},
|
},
|
||||||
open({ languageId, chapterId, lessonId, courseId, initialPool = null, srsMode = false, closeOnHardCompletion = false, onDailyHardLimitReached = null, onClose = null }) {
|
open({ languageId, chapterId, lessonId, courseId, initialPool = null, initialHardPool = null, srsMode = false, closeOnHardCompletion = false, onClose = null }) {
|
||||||
if (this.autoAdvanceTimer) {
|
if (this.autoAdvanceTimer) {
|
||||||
clearTimeout(this.autoAdvanceTimer);
|
clearTimeout(this.autoAdvanceTimer);
|
||||||
this.autoAdvanceTimer = null;
|
this.autoAdvanceTimer = null;
|
||||||
@@ -615,7 +542,6 @@ export default {
|
|||||||
console.debug('[VocabPracticeDialog] open called with', { languageId, chapterId, lessonId, courseId, srsMode, hasInitialPool: Array.isArray(initialPool) });
|
console.debug('[VocabPracticeDialog] open called with', { languageId, chapterId, lessonId, courseId, srsMode, hasInitialPool: Array.isArray(initialPool) });
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
this.onClose = typeof onClose === 'function' ? onClose : null;
|
this.onClose = typeof onClose === 'function' ? onClose : null;
|
||||||
this.onDailyHardLimitReached = typeof onDailyHardLimitReached === 'function' ? onDailyHardLimitReached : null;
|
|
||||||
this.srsMode = Boolean(srsMode);
|
this.srsMode = Boolean(srsMode);
|
||||||
this.initialPool = Array.isArray(initialPool) ? initialPool : null;
|
this.initialPool = Array.isArray(initialPool) ? initialPool : null;
|
||||||
this.closeOnHardCompletion = Boolean(closeOnHardCompletion);
|
this.closeOnHardCompletion = Boolean(closeOnHardCompletion);
|
||||||
@@ -639,9 +565,15 @@ export default {
|
|||||||
this.$refs.dialog.open();
|
this.$refs.dialog.open();
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
document.addEventListener('keydown', this.handleKeyDown);
|
document.addEventListener('keydown', this.handleKeyDown);
|
||||||
if (this.srsMode) this.maybeStartDailyHardPractice();
|
|
||||||
});
|
});
|
||||||
this.loadHardVocabMap();
|
this.loadHardVocabMap();
|
||||||
|
if (Array.isArray(initialHardPool)) {
|
||||||
|
initialHardPool.forEach((item) => {
|
||||||
|
const key = this.getHardKey(item);
|
||||||
|
if (!key || !item?.learning || !item?.reference) return;
|
||||||
|
this.hardVocabMap[key] = { learning: item.learning, reference: item.reference, itemKey: item.itemKey || item.id || null };
|
||||||
|
});
|
||||||
|
}
|
||||||
this.reloadPool();
|
this.reloadPool();
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
@@ -1198,7 +1130,7 @@ export default {
|
|||||||
// ignore autoplay issues
|
// ignore autoplay issues
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
reportSrsReview(isCorrect, rating = null) {
|
reportSrsReview(isCorrect) {
|
||||||
if (!this.current || !this.openParams?.courseId) {
|
if (!this.current || !this.openParams?.courseId) {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
@@ -1209,8 +1141,7 @@ export default {
|
|||||||
learning: this.current.learning,
|
learning: this.current.learning,
|
||||||
reference: this.current.reference,
|
reference: this.current.reference,
|
||||||
direction: this.direction,
|
direction: this.direction,
|
||||||
correct: Boolean(isCorrect),
|
correct: Boolean(isCorrect)
|
||||||
rating
|
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.warn('[VocabPracticeDialog] SRS review could not be saved:', error);
|
console.warn('[VocabPracticeDialog] SRS review could not be saved:', error);
|
||||||
});
|
});
|
||||||
@@ -1239,9 +1170,7 @@ export default {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!this.srsMode) {
|
if (this.srsMode) this.finishSrsAnswer(isCorrect);
|
||||||
this.reportSrsReview(isCorrect);
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = this.current?.id;
|
const id = this.current?.id;
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -1259,6 +1188,7 @@ export default {
|
|||||||
this.hardMasteryByKey[key] = Math.max(0, Number(this.hardMasteryByKey[key]) || 0) + 1;
|
this.hardMasteryByKey[key] = Math.max(0, Number(this.hardMasteryByKey[key]) || 0) + 1;
|
||||||
const mapKey = this.findMatchingHardKey(this.current);
|
const mapKey = this.findMatchingHardKey(this.current);
|
||||||
if ((Number(this.hardMasteryByKey[key]) || 0) >= HARD_REQUIRED_CONSECUTIVE_CORRECT && mapKey) {
|
if ((Number(this.hardMasteryByKey[key]) || 0) >= HARD_REQUIRED_CONSECUTIVE_CORRECT && mapKey) {
|
||||||
|
this.clearSrsHardStatus(this.current);
|
||||||
const next = { ...this.hardVocabMap };
|
const next = { ...this.hardVocabMap };
|
||||||
delete next[mapKey];
|
delete next[mapKey];
|
||||||
this.hardVocabMap = next;
|
this.hardVocabMap = next;
|
||||||
@@ -1282,87 +1212,38 @@ export default {
|
|||||||
this.lastIds.unshift(id);
|
this.lastIds.unshift(id);
|
||||||
this.lastIds = this.lastIds.slice(0, 3);
|
this.lastIds = this.lastIds.slice(0, 3);
|
||||||
},
|
},
|
||||||
async submitSrsRating(rating) {
|
clearSrsHardStatus(item) {
|
||||||
if (!this.srsMode || !this.answered || this.locked) {
|
const itemKey = String(item?.itemKey || item?.id || '').trim();
|
||||||
return;
|
if (!itemKey || !this.openParams?.courseId) return;
|
||||||
}
|
apiClient.patch('/api/vocab/srs/hard', {
|
||||||
|
courseId: this.openParams.courseId,
|
||||||
|
itemKey
|
||||||
|
}).catch((error) => {
|
||||||
|
console.warn('[VocabPracticeDialog] hard status could not be cleared:', error);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async finishSrsAnswer(isCorrect) {
|
||||||
|
if (!this.srsMode || !this.answered || this.locked) return;
|
||||||
const id = this.current?.id;
|
const id = this.current?.id;
|
||||||
const treatAsAgain = rating === 'again' || !this.lastCorrect;
|
|
||||||
const moveToHardPractice = treatAsAgain && this.isItemMarkedHard(this.current);
|
|
||||||
const retryPendingIds = Array.isArray(this.srsSession?.retryPendingIds)
|
|
||||||
? this.srsSession.retryPendingIds
|
|
||||||
: [];
|
|
||||||
const needsFinalRepeat = this.lastCorrect && retryPendingIds.includes(id);
|
|
||||||
|
|
||||||
this.locked = true;
|
this.locked = true;
|
||||||
// The immediate correction after a wrong answer is a rehearsal, not the
|
if (!isCorrect) this.markCurrentAsHard();
|
||||||
// SRS result. Only the later repetition at the end is recorded as right.
|
await this.reportSrsReview(isCorrect);
|
||||||
if (!needsFinalRepeat) {
|
|
||||||
await this.reportSrsReview(this.lastCorrect, rating);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id && this.srsSession) {
|
if (id && this.srsSession) {
|
||||||
if (treatAsAgain && !moveToHardPractice) {
|
const done = Array.isArray(this.srsSession.doneIds) ? this.srsSession.doneIds : [];
|
||||||
// Keep the card at the front of the queue. After the solution has
|
if (!done.includes(id)) {
|
||||||
// been shown, the user must type it correctly before moving on.
|
done.push(id);
|
||||||
if (Array.isArray(this.srsSession.doneIds)) {
|
this.srsSession.doneIds = done;
|
||||||
this.srsSession.doneIds = this.srsSession.doneIds.filter((x) => x !== id);
|
|
||||||
}
|
|
||||||
if (!retryPendingIds.includes(id)) {
|
|
||||||
this.srsSession.retryPendingIds = [...retryPendingIds, id];
|
|
||||||
}
|
|
||||||
} else if (needsFinalRepeat) {
|
|
||||||
// The correction was right, but the item must be recalled once more
|
|
||||||
// after the rest of today's cards before it counts as complete.
|
|
||||||
this.srsSession.retryPendingIds = retryPendingIds.filter((x) => x !== id);
|
|
||||||
const finalReviewIds = Array.isArray(this.srsSession.finalReviewIds)
|
|
||||||
? this.srsSession.finalReviewIds
|
|
||||||
: [];
|
|
||||||
if (!finalReviewIds.includes(id)) {
|
|
||||||
this.srsSession.finalReviewIds = [...finalReviewIds, id];
|
|
||||||
}
|
|
||||||
const remaining = Array.isArray(this.srsQueueIds)
|
|
||||||
? this.srsQueueIds.filter((x) => x !== id)
|
|
||||||
: [];
|
|
||||||
remaining.push(id);
|
|
||||||
this.srsQueueIds = remaining;
|
|
||||||
} else {
|
|
||||||
// Hard cards leave the daily review after a wrong answer. They are
|
|
||||||
// practiced separately through the hard-vocab drill.
|
|
||||||
const done = Array.isArray(this.srsSession.doneIds) ? this.srsSession.doneIds : [];
|
|
||||||
if (!done.includes(id)) {
|
|
||||||
done.push(id);
|
|
||||||
this.srsSession.doneIds = done;
|
|
||||||
}
|
|
||||||
this.srsSession.retryPendingIds = retryPendingIds.filter((x) => x !== id);
|
|
||||||
this.srsSession.finalReviewIds = (Array.isArray(this.srsSession.finalReviewIds)
|
|
||||||
? this.srsSession.finalReviewIds
|
|
||||||
: []).filter((x) => x !== id);
|
|
||||||
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.srsQueueIds = Array.isArray(this.srsQueueIds)
|
||||||
|
? this.srsQueueIds.filter((x) => x !== id)
|
||||||
|
: [];
|
||||||
this.saveSrsSession();
|
this.saveSrsSession();
|
||||||
}
|
}
|
||||||
|
this.autoAdvanceTimer = setTimeout(() => {
|
||||||
if (treatAsAgain && !moveToHardPractice) {
|
this.autoAdvanceTimer = null;
|
||||||
const retryItem = this.pool.find((item) => item.id === id) || this.current;
|
this.next();
|
||||||
const retryDirection = this.direction;
|
}, isCorrect ? 450 : 1300);
|
||||||
this.resetQuestion();
|
|
||||||
this.current = retryItem;
|
|
||||||
this.direction = retryDirection;
|
|
||||||
const prompt = this.currentAnswerPrompt;
|
|
||||||
this.acceptableAnswers = this.getAnswersForPrompt(prompt, this.direction);
|
|
||||||
if (this.simpleMode) this.buildChoices();
|
|
||||||
this.$nextTick(() => {
|
|
||||||
if (!this.simpleMode) this.$refs.answerInput?.focus?.();
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.next();
|
|
||||||
},
|
},
|
||||||
submitChoice(opt) {
|
submitChoice(opt) {
|
||||||
if (this.locked) return;
|
if (this.locked) return;
|
||||||
|
|||||||
@@ -870,10 +870,10 @@
|
|||||||
"reviewTimeNow": "jetzt",
|
"reviewTimeNow": "jetzt",
|
||||||
"reviewTimeTomorrow": "morgen",
|
"reviewTimeTomorrow": "morgen",
|
||||||
"reviewTimeInDays": "in {count} Tagen",
|
"reviewTimeInDays": "in {count} Tagen",
|
||||||
"srsDueStat": "SRS fällig: {count}",
|
"srsDueStat": "Tageswiederholung: {scheduled} von {total}",
|
||||||
"srsEyebrow": "Langzeitgedächtnis",
|
"srsEyebrow": "Langzeitgedächtnis",
|
||||||
"srsTitle": "{count} Begriffe sind heute fällig",
|
"srsTitle": "Heute: {scheduled} von {total} fälligen Begriffen",
|
||||||
"srsIntro": "Diese Wiederholung kommt aus dem SRS-Plan einzelner Begriffe. Sie hat Vorrang vor neuem Stoff, weil sie kurz vor dem Vergessen stabilisiert.",
|
"srsIntro": "Diese Wiederholung kommt aus dem SRS-Plan einzelner Begriffe. Pro Tag werden höchstens 50 Begriffe eingeplant; weitere fällige Begriffe bleiben für die nächste Tageswiederholung erhalten.",
|
||||||
"srsStart": "Tageswiederholung starten",
|
"srsStart": "Tageswiederholung starten",
|
||||||
"courseTodayPlanIntroSrs": "Didaktische Reihenfolge: Zuerst die fällige SRS-Tageswiederholung einzelner Begriffe. Danach kommen Kurz-Wiederholungen, Blockfortschritt und ggf. intensive Wiederholung. So wird altes Material stabilisiert, bevor neues Material dazukommt."
|
"courseTodayPlanIntroSrs": "Didaktische Reihenfolge: Zuerst die fällige SRS-Tageswiederholung einzelner Begriffe. Danach kommen Kurz-Wiederholungen, Blockfortschritt und ggf. intensive Wiederholung. So wird altes Material stabilisiert, bevor neues Material dazukommt."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -870,10 +870,10 @@
|
|||||||
"reviewTimeNow": "now",
|
"reviewTimeNow": "now",
|
||||||
"reviewTimeTomorrow": "tomorrow",
|
"reviewTimeTomorrow": "tomorrow",
|
||||||
"reviewTimeInDays": "in {count} days",
|
"reviewTimeInDays": "in {count} days",
|
||||||
"srsDueStat": "SRS due: {count}",
|
"srsDueStat": "Daily review: {scheduled} of {total}",
|
||||||
"srsEyebrow": "Long-term memory",
|
"srsEyebrow": "Long-term memory",
|
||||||
"srsTitle": "{count} terms are due today",
|
"srsTitle": "Today: {scheduled} of {total} due terms",
|
||||||
"srsIntro": "This review comes from the SRS schedule of individual terms. It should come before new material because it stabilizes items close to forgetting.",
|
"srsIntro": "This review comes from the SRS schedule of individual terms. At most 50 terms are planned per day; any additional due terms remain for the next daily review.",
|
||||||
"srsStart": "Start daily review",
|
"srsStart": "Start daily review",
|
||||||
"courseTodayPlanIntroSrs": "Pedagogical order: start with the due SRS daily review for individual terms. Then quick reviews, block progress, and intensive review if needed. This stabilizes old material before new material is added."
|
"courseTodayPlanIntroSrs": "Pedagogical order: start with the due SRS daily review for individual terms. Then quick reviews, block progress, and intensive review if needed. This stabilizes old material before new material is added."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,13 +46,6 @@
|
|||||||
<strong>{{ entry.learning }}</strong>
|
<strong>{{ entry.learning }}</strong>
|
||||||
<span>{{ entry.reference }}</span>
|
<span>{{ entry.reference }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-delete"
|
|
||||||
@click="removeHardVocabEntry(entry.key)"
|
|
||||||
>
|
|
||||||
{{ $t('socialnetwork.vocab.courses.unmarkVocabHard') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -85,7 +78,7 @@
|
|||||||
<p>{{ $t('socialnetwork.vocab.courses.courseFlowIntro') }}</p>
|
<p>{{ $t('socialnetwork.vocab.courses.courseFlowIntro') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="course-flow__stats">
|
<div class="course-flow__stats">
|
||||||
<span class="course-flow__stat">{{ $t('socialnetwork.vocab.courses.srsDueStat', { count: srsDueCount }) }}</span>
|
<span class="course-flow__stat">{{ $t('socialnetwork.vocab.courses.srsDueStat', { scheduled: srsDailyCount, total: srsDueCount }) }}</span>
|
||||||
<span class="course-flow__stat">{{ $t('socialnetwork.vocab.courses.courseFlowReviewStat', { count: dueReviewLessons.length }) }}</span>
|
<span class="course-flow__stat">{{ $t('socialnetwork.vocab.courses.courseFlowReviewStat', { count: dueReviewLessons.length }) }}</span>
|
||||||
<span class="course-flow__stat">{{ $t('socialnetwork.vocab.courses.courseFlowBlockStat', { block: currentBlockNumber || '—' }) }}</span>
|
<span class="course-flow__stat">{{ $t('socialnetwork.vocab.courses.courseFlowBlockStat', { block: currentBlockNumber || '—' }) }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,7 +87,7 @@
|
|||||||
<div v-if="srsDueCount > 0" class="course-srs-plan">
|
<div v-if="srsDueCount > 0" class="course-srs-plan">
|
||||||
<div>
|
<div>
|
||||||
<span class="course-srs-plan__eyebrow">{{ $t('socialnetwork.vocab.courses.srsEyebrow') }}</span>
|
<span class="course-srs-plan__eyebrow">{{ $t('socialnetwork.vocab.courses.srsEyebrow') }}</span>
|
||||||
<h4>{{ $t('socialnetwork.vocab.courses.srsTitle', { count: srsDueCount }) }}</h4>
|
<h4>{{ $t('socialnetwork.vocab.courses.srsTitle', { scheduled: srsDailyCount, total: srsDueCount }) }}</h4>
|
||||||
<p>{{ $t('socialnetwork.vocab.courses.srsIntro') }}</p>
|
<p>{{ $t('socialnetwork.vocab.courses.srsIntro') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="course-today-plan__action" :disabled="srsLoading" @click="openSrsPractice">
|
<button type="button" class="course-today-plan__action" :disabled="srsLoading" @click="openSrsPractice">
|
||||||
@@ -378,6 +371,7 @@ export default {
|
|||||||
chapters: [],
|
chapters: [],
|
||||||
srsDueItems: [],
|
srsDueItems: [],
|
||||||
srsDueTotal: 0,
|
srsDueTotal: 0,
|
||||||
|
srsDailyLimit: 50,
|
||||||
srsLoading: false,
|
srsLoading: false,
|
||||||
showAddLessonDialog: false,
|
showAddLessonDialog: false,
|
||||||
assistantSettings: null,
|
assistantSettings: null,
|
||||||
@@ -427,6 +421,9 @@ export default {
|
|||||||
}
|
}
|
||||||
return Array.isArray(this.srsDueItems) ? this.srsDueItems.length : 0;
|
return Array.isArray(this.srsDueItems) ? this.srsDueItems.length : 0;
|
||||||
},
|
},
|
||||||
|
srsDailyCount() {
|
||||||
|
return Math.min(this.srsDueCount, this.srsDailyLimit);
|
||||||
|
},
|
||||||
hardVocabCount() {
|
hardVocabCount() {
|
||||||
return Array.isArray(this.hardVocabList) ? this.hardVocabList.length : 0;
|
return Array.isArray(this.hardVocabList) ? this.hardVocabList.length : 0;
|
||||||
},
|
},
|
||||||
@@ -566,58 +563,27 @@ export default {
|
|||||||
displayCourseTitle(course) {
|
displayCourseTitle(course) {
|
||||||
return localizeVocabCourseTitle(course?.title, this.$i18n?.locale) || '';
|
return localizeVocabCourseTitle(course?.title, this.$i18n?.locale) || '';
|
||||||
},
|
},
|
||||||
hardStorageKey() {
|
async refreshHardVocabList() {
|
||||||
return this.courseId ? `yourpart:vocab:hardList:${this.courseId}` : null;
|
if (!this.courseId) {
|
||||||
},
|
|
||||||
refreshHardVocabList() {
|
|
||||||
const key = this.hardStorageKey();
|
|
||||||
if (!key) {
|
|
||||||
this.hardVocabList = [];
|
this.hardVocabList = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(key);
|
const { data } = await apiClient.get(`/api/vocab/courses/${this.courseId}/srs/hard`);
|
||||||
const parsed = raw ? JSON.parse(raw) : {};
|
this.hardVocabList = (Array.isArray(data?.items) ? data.items : [])
|
||||||
const values = parsed && typeof parsed === 'object' ? Object.entries(parsed) : [];
|
.map((entry) => ({
|
||||||
this.hardVocabList = values
|
id: String(entry.itemKey || ''),
|
||||||
.map(([entryKey, entry], idx) => {
|
key: String(entry.itemKey || ''),
|
||||||
const learning = String(entry?.learning || '').trim();
|
itemKey: String(entry.itemKey || ''),
|
||||||
const reference = String(entry?.reference || '').trim();
|
lessonId: entry.lessonId || null,
|
||||||
if (!learning || !reference) return null;
|
learning: String(entry.learning || '').trim(),
|
||||||
return {
|
reference: String(entry.reference || '').trim()
|
||||||
id: `hard-${idx}-${learning}-${reference}`,
|
}))
|
||||||
key: String(entryKey || ''),
|
.filter((entry) => entry.id && entry.learning && entry.reference)
|
||||||
learning,
|
|
||||||
reference
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
this.hardVocabList = [];
|
this.hardVocabList = [];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
removeHardVocabEntry(entryKey) {
|
|
||||||
const key = this.hardStorageKey();
|
|
||||||
if (!key || !entryKey) return;
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(key);
|
|
||||||
const parsed = raw ? JSON.parse(raw) : {};
|
|
||||||
if (!parsed || typeof parsed !== 'object' || !parsed[entryKey]) return;
|
|
||||||
const next = { ...parsed };
|
|
||||||
delete next[entryKey];
|
|
||||||
localStorage.setItem(key, JSON.stringify(next));
|
|
||||||
this.refreshHardVocabList();
|
|
||||||
try {
|
|
||||||
window.dispatchEvent(new CustomEvent('yourpart:hardvocab:changed', {
|
|
||||||
detail: { courseId: this.courseId, storageKey: key }
|
|
||||||
}));
|
|
||||||
} catch (_) {
|
|
||||||
// ignore environments without CustomEvent
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// ignore storage parse/write errors
|
|
||||||
}
|
|
||||||
},
|
|
||||||
handleWindowFocus() {
|
handleWindowFocus() {
|
||||||
this.refreshHardVocabList();
|
this.refreshHardVocabList();
|
||||||
},
|
},
|
||||||
@@ -657,14 +623,16 @@ export default {
|
|||||||
this.srsLoading = true;
|
this.srsLoading = true;
|
||||||
try {
|
try {
|
||||||
const { data } = await apiClient.get(`/api/vocab/courses/${this.courseId}/srs/due`, {
|
const { data } = await apiClient.get(`/api/vocab/courses/${this.courseId}/srs/due`, {
|
||||||
params: { limit: 40 }
|
params: { limit: 50 }
|
||||||
});
|
});
|
||||||
this.srsDueItems = Array.isArray(data?.items) ? data.items : [];
|
this.srsDueItems = Array.isArray(data?.items) ? data.items : [];
|
||||||
this.srsDueTotal = Number.isFinite(Number(data?.totalDueCount)) ? Number(data.totalDueCount) : this.srsDueItems.length;
|
this.srsDueTotal = Number.isFinite(Number(data?.totalDueCount)) ? Number(data.totalDueCount) : this.srsDueItems.length;
|
||||||
|
this.srsDailyLimit = Number.isFinite(Number(data?.dailyLimit)) ? Number(data.dailyLimit) : 50;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Konnte SRS-Fälligkeiten nicht laden:', e);
|
console.warn('Konnte SRS-Fälligkeiten nicht laden:', e);
|
||||||
this.srsDueItems = [];
|
this.srsDueItems = [];
|
||||||
this.srsDueTotal = 0;
|
this.srsDueTotal = 0;
|
||||||
|
this.srsDailyLimit = 50;
|
||||||
} finally {
|
} finally {
|
||||||
this.srsLoading = false;
|
this.srsLoading = false;
|
||||||
}
|
}
|
||||||
@@ -966,6 +934,7 @@ export default {
|
|||||||
this.$refs.practiceDialog?.open?.({
|
this.$refs.practiceDialog?.open?.({
|
||||||
courseId: this.courseId,
|
courseId: this.courseId,
|
||||||
initialPool: this.hardVocabList,
|
initialPool: this.hardVocabList,
|
||||||
|
initialHardPool: this.hardVocabList,
|
||||||
closeOnHardCompletion: true,
|
closeOnHardCompletion: true,
|
||||||
onClose: () => this.refreshHardVocabList()
|
onClose: () => this.refreshHardVocabList()
|
||||||
});
|
});
|
||||||
@@ -978,10 +947,6 @@ export default {
|
|||||||
courseId: this.courseId,
|
courseId: this.courseId,
|
||||||
initialPool: this.srsDueItems,
|
initialPool: this.srsDueItems,
|
||||||
srsMode: true,
|
srsMode: true,
|
||||||
onDailyHardLimitReached: () => {
|
|
||||||
this.refreshHardVocabList();
|
|
||||||
this.$nextTick(() => this.openHardPractice());
|
|
||||||
},
|
|
||||||
onClose: () => this.loadSrsDueItems()
|
onClose: () => this.loadSrsDueItems()
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -1040,11 +1005,9 @@ export default {
|
|||||||
]);
|
]);
|
||||||
this.refreshHardVocabList();
|
this.refreshHardVocabList();
|
||||||
window.addEventListener('focus', this.handleWindowFocus);
|
window.addEventListener('focus', this.handleWindowFocus);
|
||||||
window.addEventListener('yourpart:hardvocab:changed', this.handleHardVocabChanged);
|
|
||||||
},
|
},
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
window.removeEventListener('focus', this.handleWindowFocus);
|
window.removeEventListener('focus', this.handleWindowFocus);
|
||||||
window.removeEventListener('yourpart:hardvocab:changed', this.handleHardVocabChanged);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -315,19 +315,6 @@
|
|||||||
{{ $t('socialnetwork.vocab.courses.wrong') }}. {{ $t('socialnetwork.vocab.courses.correctAnswer') }}: {{ currentVocabQuestion.answer }}
|
{{ $t('socialnetwork.vocab.courses.wrong') }}. {{ $t('socialnetwork.vocab.courses.correctAnswer') }}: {{ currentVocabQuestion.answer }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="vocab-hard-actions">
|
|
||||||
<button type="button" class="btn-switch-mode" @click="markCurrentVocabAsHard">
|
|
||||||
{{ $t('socialnetwork.vocab.courses.markVocabHard') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="isCurrentVocabMarkedHard"
|
|
||||||
type="button"
|
|
||||||
class="btn-switch-mode"
|
|
||||||
@click="unmarkCurrentVocabAsHard"
|
|
||||||
>
|
|
||||||
{{ $t('socialnetwork.vocab.courses.unmarkVocabHard') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- Multiple Choice Modus -->
|
<!-- Multiple Choice Modus -->
|
||||||
<div v-if="vocabTrainerMode === 'multiple_choice' && !vocabTrainerAnswered" class="vocab-answer-area multiple-choice">
|
<div v-if="vocabTrainerMode === 'multiple_choice' && !vocabTrainerAnswered" class="vocab-answer-area multiple-choice">
|
||||||
<div class="choice-buttons">
|
<div class="choice-buttons">
|
||||||
|
|||||||
Reference in New Issue
Block a user