Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion projects/app/src/data/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
Expand Down
23 changes: 23 additions & 0 deletions projects/app/test/client/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
},
);

Expand Down
55 changes: 50 additions & 5 deletions projects/app/test/data/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
Expand Down Expand Up @@ -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,
}),
Expand All @@ -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,
}),
Expand All @@ -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 () => {
Expand Down
Loading