diff --git a/frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue b/frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue index b94c8ec..a7e347f 100755 --- a/frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue +++ b/frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue @@ -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 };