feat(vocab): add hard status management for SRS items and update related logic

This commit is contained in:
Torsten Schulz (local)
2026-08-20 15:17:11 +02:00
parent a8a13ac25a
commit ad02dd006f
10 changed files with 181 additions and 263 deletions

View File

@@ -45,22 +45,6 @@
</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="simpleMode" class="choices">
<button
@@ -94,12 +78,6 @@
<button v-else-if="showSkipButton" @click="skip">
{{ $t('socialnetwork.vocab.practice.skip') }}
</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 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 HARD_REQUIRED_CONSECUTIVE_CORRECT = 5;
const MAX_DAILY_DUE = 50;
const MAX_DAILY_HARD_VOCABS = 7;
export default {
name: 'VocabPracticeDialog',
@@ -171,7 +148,6 @@ export default {
return {
openParams: null, // { languageId, chapterId, lessonId, courseId }
onClose: null,
onDailyHardLimitReached: null,
loading: false,
allVocabs: false,
srsMode: false,
@@ -258,9 +234,6 @@ export default {
showSkipButton() {
return !this.answered;
},
showSrsRatingButtons() {
return this.srsMode && this.answered && !this.locked;
},
visibleCorrectAnswers() {
const answers = Array.isArray(this.acceptableAnswers) ? this.acceptableAnswers.filter(Boolean) : [];
if (answers.length > 0) {
@@ -272,37 +245,6 @@ export default {
const fallback = this.direction === 'L2R' ? this.current.reference : this.current.learning;
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() {
return Number(this.srsSession?.initialTotalDue || 0) || 0;
},
@@ -446,21 +388,6 @@ export default {
this.hardMasteryByKey[key] = 0;
}
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() {
if (!this.current) return;
@@ -604,7 +531,7 @@ export default {
}
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) {
clearTimeout(this.autoAdvanceTimer);
this.autoAdvanceTimer = null;
@@ -615,7 +542,6 @@ export default {
console.debug('[VocabPracticeDialog] open called with', { languageId, chapterId, lessonId, courseId, srsMode, hasInitialPool: Array.isArray(initialPool) });
} catch (_) {}
this.onClose = typeof onClose === 'function' ? onClose : null;
this.onDailyHardLimitReached = typeof onDailyHardLimitReached === 'function' ? onDailyHardLimitReached : null;
this.srsMode = Boolean(srsMode);
this.initialPool = Array.isArray(initialPool) ? initialPool : null;
this.closeOnHardCompletion = Boolean(closeOnHardCompletion);
@@ -639,9 +565,15 @@ export default {
this.$refs.dialog.open();
this.$nextTick(() => {
document.addEventListener('keydown', this.handleKeyDown);
if (this.srsMode) this.maybeStartDailyHardPractice();
});
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();
},
close() {
@@ -1198,7 +1130,7 @@ export default {
// ignore autoplay issues
}
},
reportSrsReview(isCorrect, rating = null) {
reportSrsReview(isCorrect) {
if (!this.current || !this.openParams?.courseId) {
return Promise.resolve();
}
@@ -1209,8 +1141,7 @@ export default {
learning: this.current.learning,
reference: this.current.reference,
direction: this.direction,
correct: Boolean(isCorrect),
rating
correct: Boolean(isCorrect)
}).catch((error) => {
console.warn('[VocabPracticeDialog] SRS review could not be saved:', error);
});
@@ -1239,9 +1170,7 @@ export default {
};
}
}
if (!this.srsMode) {
this.reportSrsReview(isCorrect);
}
if (this.srsMode) this.finishSrsAnswer(isCorrect);
const id = this.current?.id;
if (!id) return;
@@ -1259,6 +1188,7 @@ export default {
this.hardMasteryByKey[key] = Math.max(0, Number(this.hardMasteryByKey[key]) || 0) + 1;
const mapKey = this.findMatchingHardKey(this.current);
if ((Number(this.hardMasteryByKey[key]) || 0) >= HARD_REQUIRED_CONSECUTIVE_CORRECT && mapKey) {
this.clearSrsHardStatus(this.current);
const next = { ...this.hardVocabMap };
delete next[mapKey];
this.hardVocabMap = next;
@@ -1282,87 +1212,38 @@ export default {
this.lastIds.unshift(id);
this.lastIds = this.lastIds.slice(0, 3);
},
async submitSrsRating(rating) {
if (!this.srsMode || !this.answered || this.locked) {
return;
}
clearSrsHardStatus(item) {
const itemKey = String(item?.itemKey || item?.id || '').trim();
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 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;
// The immediate correction after a wrong answer is a rehearsal, not the
// SRS result. Only the later repetition at the end is recorded as right.
if (!needsFinalRepeat) {
await this.reportSrsReview(this.lastCorrect, rating);
}
if (!isCorrect) this.markCurrentAsHard();
await this.reportSrsReview(isCorrect);
if (id && this.srsSession) {
if (treatAsAgain && !moveToHardPractice) {
// Keep the card at the front of the queue. After the solution has
// been shown, the user must type it correctly before moving on.
if (Array.isArray(this.srsSession.doneIds)) {
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);
}
const done = Array.isArray(this.srsSession.doneIds) ? this.srsSession.doneIds : [];
if (!done.includes(id)) {
done.push(id);
this.srsSession.doneIds = done;
}
this.srsQueueIds = Array.isArray(this.srsQueueIds)
? this.srsQueueIds.filter((x) => x !== id)
: [];
this.saveSrsSession();
}
if (treatAsAgain && !moveToHardPractice) {
const retryItem = this.pool.find((item) => item.id === id) || this.current;
const retryDirection = this.direction;
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();
this.autoAdvanceTimer = setTimeout(() => {
this.autoAdvanceTimer = null;
this.next();
}, isCorrect ? 450 : 1300);
},
submitChoice(opt) {
if (this.locked) return;