diff --git a/projects/app/src/data/skills.ts b/projects/app/src/data/skills.ts index b219807bd2..20fc7c4840 100644 --- a/projects/app/src/data/skills.ts +++ b/projects/app/src/data/skills.ts @@ -22,6 +22,7 @@ import { } from "@pinyinly/lib/collections"; import { invariant } from "@pinyinly/lib/invariant"; import type { Duration } from "date-fns"; +import { add } from "date-fns/add"; import { sub } from "date-fns/sub"; import { subDays } from "date-fns/subDays"; import type { DeepReadonly } from "ts-essentials"; @@ -602,7 +603,14 @@ export function skillReviewQueue({ } else { const skillRating = latestSkillRatings.get(skill); if (skillRating?.rating === Rating.Again) { - learningOrderRetry.push([skill, skillRating.createdAt]); + const debounceEndTime = add(skillRating.createdAt, skillRetryDebounce); + if (debounceEndTime <= now) { + // Debounce period has passed, treat as retry + learningOrderRetry.push([skill, skillRating.createdAt]); + } else { + // Still in debounce period, treat as due at debounce end time + learningOrderDue.push([skill, debounceEndTime]); + } } else if (srsState.nextReviewAt > now) { // Check if it should be the new newDueAt. if (newDueAt == null || newDueAt > srsState.nextReviewAt) { @@ -868,6 +876,13 @@ export function skillKindToShorthand(skillKind: SkillKind): string { */ export const skillDueWindow: Duration = { hours: 24 }; +/** + * Minimum time to wait before retry items (incorrect answers) become available + * again. This prevents consecutively asking related questions that would make + * the answers too obvious. + */ +export const skillRetryDebounce: Duration = { minutes: 5 }; + export function isOverdue(srsState: SrsStateType, now: Date): boolean { return srsState.nextReviewAt < sub(now, skillDueWindow); } diff --git a/projects/app/test/client/query.test.ts b/projects/app/test/client/query.test.ts index 4cba7f9cac..a38f744633 100644 --- a/projects/app/test/client/query.test.ts +++ b/projects/app/test/client/query.test.ts @@ -263,6 +263,29 @@ describe( }); } }); + + test(`retry items are debounced for 5 minutes before becoming available again`, async () => { + const targetSkills: Skill[] = [`he:八:eight`]; + const history: SkillReviewOp[] = [ + `❌ he:八:eight`, // Get it wrong initially + `💤 2m`, // Wait 2 minutes (less than 5 minute debounce) + ]; + + // Within debounce period, item should be in due queue, not retry queue + const queue1 = await simulateSkillReviews({ targetSkills, history }); + expect(queue1.retryCount).toBe(0); // Not in retry queue yet + expect(queue1.dueCount).toBe(1); // In due queue instead + expect(queue1.items[0]).toBe(`he:八:eight`); // Still available, but as due item + + // Wait past the debounce period + history.push(`💤 4m`); // Total wait is now 6 minutes + + // After debounce period, item should be in retry queue + const queue2 = await simulateSkillReviews({ targetSkills, history }); + expect(queue2.retryCount).toBe(1); // Now in retry queue + expect(queue2.dueCount).toBe(0); // No longer in due queue + expect(queue2.items[0]).toBe(`he:八:eight`); // Still prioritized at front + }); }, ); diff --git a/projects/app/test/data/skills.test.ts b/projects/app/test/data/skills.test.ts index 914a7d0be3..7a5fb2860c 100644 --- a/projects/app/test/data/skills.test.ts +++ b/projects/app/test/data/skills.test.ts @@ -509,7 +509,7 @@ describe( [`he:丿:slash`, mockSrsState(时`-1d`, 时`-3m`)], ]), latestSkillRatings: new Map([ - [`he:丿:slash`, { rating: Rating.Again, createdAt: 时`-1m` }], + [`he:丿:slash`, { rating: Rating.Again, createdAt: 时`-6m` }], // Past debounce period ]), isStructuralHanziWord, }), @@ -571,8 +571,8 @@ describe( [`he:丿:slash`, mockSrsState(时`-1d`, 时`-5m`)], ]), latestSkillRatings: new Map([ - [`he:八:eight`, { rating: Rating.Again, createdAt: 时`-1m` }], - [`he:丿:slash`, { rating: Rating.Again, createdAt: 时`-2m` }], + [`he:八:eight`, { rating: Rating.Again, createdAt: 时`-6m` }], + [`he:丿:slash`, { rating: Rating.Again, createdAt: 时`-7m` }], ]), isStructuralHanziWord, }), @@ -594,8 +594,8 @@ describe( [`he:丿:slash`, mockSrsState(时`-1d`, 时`-5m`)], ]), latestSkillRatings: new Map([ - [`he:八:eight`, { rating: Rating.Again, createdAt: 时`-2m` }], - [`he:丿:slash`, { rating: Rating.Again, createdAt: 时`-1m` }], + [`he:八:eight`, { rating: Rating.Again, createdAt: 时`-7m` }], + [`he:丿:slash`, { rating: Rating.Again, createdAt: 时`-6m` }], ]), isStructuralHanziWord, }), @@ -609,6 +609,51 @@ describe( }); }, ); + + skillTest( + `failed skills are debounced for 5 minutes before becoming retry items`, + async ({ isStructuralHanziWord }) => { + const graph = await skillLearningGraph({ + targetSkills: [`he:八:eight`], + }); + + // Test case 1: Within debounce period (skill failed 2 minutes ago) + expect( + skillReviewQueue({ + graph, + skillSrsStates: new Map([ + [`he:八:eight`, mockSrsState(时`-1d`, 时`-10m`)], + ]), + latestSkillRatings: new Map([ + [`he:八:eight`, { rating: Rating.Again, createdAt: 时`-2m` }], + ]), + isStructuralHanziWord, + }), + ).toMatchObject({ + retryCount: 0, // Not yet available for retry + dueCount: 1, // In due queue instead + items: [`he:八:eight`], // Still available, but as due item + }); + + // Test case 2: After debounce period (skill failed 6 minutes ago) + expect( + skillReviewQueue({ + graph, + skillSrsStates: new Map([ + [`he:八:eight`, mockSrsState(时`-1d`, 时`-10m`)], + ]), + latestSkillRatings: new Map([ + [`he:八:eight`, { rating: Rating.Again, createdAt: 时`-6m` }], + ]), + isStructuralHanziWord, + }), + ).toMatchObject({ + retryCount: 1, // Now available for retry + dueCount: 0, // No longer in due queue + items: [`he:八:eight`], // Still prioritized at front + }); + }, + ); }); test(`prioritises due skills with highest value (rather than most over-due)`, async () => {