fix(vocab): keep SRS cards and counters aligned
All checks were successful
Deploy to production / deploy (push) Successful in 2m56s

This commit is contained in:
Torsten Schulz (local)
2026-10-05 11:31:20 +02:00
parent 74c9c040c0
commit 14cfc3d2b5

View File

@@ -143,9 +143,9 @@ const PRACTICE_MIN_EXPOSURES = 3;
// The original daily batch remains stable when the dialog is reopened.
// Keep in sync with VocabCourseView. v3 discards legacy empty sessions that
// could block cards due later on the same calendar day.
// Keep this in sync with VocabCourseView. Version 4 discards stale empty
// sessions that could mask a new daily SRS batch.
const SRS_SESSION_STORAGE_VERSION = 4;
// Keep this in sync with VocabCourseView. Version 5 discards sessions that
// counted cards removed by the client-side duplicate filter as still open.
const SRS_SESSION_STORAGE_VERSION = 5;
const HARD_REQUIRED_CONSECUTIVE_CORRECT = 5;
const MAX_DAILY_DUE = 50;
const MAX_DAILY_HARD_VOCABS = 7;
@@ -517,7 +517,11 @@ export default {
}
if (!this.srsSession) {
const initialTotal = Math.min(MAX_DAILY_DUE, Number.isFinite(Number(this.srsServerTotalDue)) ? Number(this.srsServerTotalDue) : dueIds.length);
// `dueIds` is the actual exercise pool. The server's total can be
// larger than the requested page (and formerly also included cards
// that the client filtered), so it must never define this batch's
// counter.
const initialTotal = Math.min(MAX_DAILY_DUE, dueIds.length);
this.srsSession = {
version: SRS_SESSION_STORAGE_VERSION,
dateKey: this.getLocalDateKey(),
@@ -551,6 +555,12 @@ export default {
const initialDueIds = Array.isArray(this.srsSession.initialDueIds)
? this.srsSession.initialDueIds.slice(0, MAX_DAILY_DUE)
: [];
// A resumed session can only count cards that were actually stored in
// its batch. This prevents an empty prompt when an older client had
// collapsed distinct SRS cards with identical wording.
if (this.srsSession.initialTotalDue !== initialDueIds.length) {
this.srsSession.initialTotalDue = initialDueIds.length;
}
const doneSet = new Set(Array.isArray(this.srsSession.doneIds) ? this.srsSession.doneIds : []);
const finalReviewIds = Array.isArray(this.srsSession.finalReviewIds) ? this.srsSession.finalReviewIds : [];
const finalReviewSet = new Set(finalReviewIds);
@@ -813,12 +823,17 @@ export default {
const learning = String(normalizedCandidate?.learning || '').trim();
const reference = String(normalizedCandidate?.reference || '').trim();
if (!this.isTrainablePair(learning, reference)) return null;
const key = `${this.normalize(learning)}|${this.normalize(reference)}`;
const pairKey = `${this.normalize(learning)}|${this.normalize(reference)}`;
// In normal practice, identical pairs are duplicates. In SRS,
// however, the same pair may intentionally occur in both
// directions and is represented by separate item keys.
const srsItemId = candidate?.id || candidate?.itemKey;
const key = this.srsMode && srsItemId ? `srs:${srsItemId}` : pairKey;
if (seen.has(key)) return null;
seen.add(key);
return {
...normalizedCandidate,
id: candidate?.id || candidate?.itemKey || candidate?.key || `${key}|${index}|${altIndex}`,
id: srsItemId || candidate?.key || `${pairKey}|${index}|${altIndex}`,
learning,
reference
};