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. // The original daily batch remains stable when the dialog is reopened.
// Keep in sync with VocabCourseView. v3 discards legacy empty sessions that // Keep in sync with VocabCourseView. v3 discards legacy empty sessions that
// could block cards due later on the same calendar day. // could block cards due later on the same calendar day.
// Keep this in sync with VocabCourseView. Version 4 discards stale empty // Keep this in sync with VocabCourseView. Version 5 discards sessions that
// sessions that could mask a new daily SRS batch. // counted cards removed by the client-side duplicate filter as still open.
const SRS_SESSION_STORAGE_VERSION = 4; const SRS_SESSION_STORAGE_VERSION = 5;
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; const MAX_DAILY_HARD_VOCABS = 7;
@@ -517,7 +517,11 @@ export default {
} }
if (!this.srsSession) { 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 = { this.srsSession = {
version: SRS_SESSION_STORAGE_VERSION, version: SRS_SESSION_STORAGE_VERSION,
dateKey: this.getLocalDateKey(), dateKey: this.getLocalDateKey(),
@@ -551,6 +555,12 @@ export default {
const initialDueIds = Array.isArray(this.srsSession.initialDueIds) const initialDueIds = Array.isArray(this.srsSession.initialDueIds)
? this.srsSession.initialDueIds.slice(0, MAX_DAILY_DUE) ? 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 doneSet = new Set(Array.isArray(this.srsSession.doneIds) ? this.srsSession.doneIds : []);
const finalReviewIds = Array.isArray(this.srsSession.finalReviewIds) ? this.srsSession.finalReviewIds : []; const finalReviewIds = Array.isArray(this.srsSession.finalReviewIds) ? this.srsSession.finalReviewIds : [];
const finalReviewSet = new Set(finalReviewIds); const finalReviewSet = new Set(finalReviewIds);
@@ -813,12 +823,17 @@ export default {
const learning = String(normalizedCandidate?.learning || '').trim(); const learning = String(normalizedCandidate?.learning || '').trim();
const reference = String(normalizedCandidate?.reference || '').trim(); const reference = String(normalizedCandidate?.reference || '').trim();
if (!this.isTrainablePair(learning, reference)) return null; 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; if (seen.has(key)) return null;
seen.add(key); seen.add(key);
return { return {
...normalizedCandidate, ...normalizedCandidate,
id: candidate?.id || candidate?.itemKey || candidate?.key || `${key}|${index}|${altIndex}`, id: srsItemId || candidate?.key || `${pairKey}|${index}|${altIndex}`,
learning, learning,
reference reference
}; };