426 lines
14 KiB
TypeScript
426 lines
14 KiB
TypeScript
// utils/BrainDataClient.ts
|
|
// Client for managing Brain study progress data with mode progression
|
|
|
|
import { kvStore } from "../modules/KvCacheStore.ts";
|
|
import {
|
|
BrainQuestionProgress,
|
|
BrainQueueItem,
|
|
KvObj,
|
|
QuizConfig,
|
|
} from "./types.ts";
|
|
import { calculateNextReview, isDue } from "./spacedRepetition.ts";
|
|
import {
|
|
DEFAULT_QUIZ_MODE,
|
|
getModeIndex,
|
|
getNextMode,
|
|
QUIZ_MODES,
|
|
} from "./quizModes.ts";
|
|
|
|
// How many days to push out review-only cards
|
|
const REVIEW_ONLY_INTERVAL_DAYS = 30;
|
|
|
|
const REVIEW_ONLY_MIN_DAYS_SINCE_LAST_REVIEW = 30;
|
|
|
|
function shuffleInPlace<T>(arr: T[]) {
|
|
for (let i = arr.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate composite ID for brain progress record (mode-agnostic)
|
|
*/
|
|
export function generateBrainProgressId(
|
|
userId: string,
|
|
questionId: string,
|
|
): string {
|
|
return `${userId}_${questionId}`;
|
|
}
|
|
|
|
/**
|
|
* Client for managing Brain study progress with mode progression
|
|
*/
|
|
export class BrainDataClient {
|
|
/**
|
|
* Get all progress records for a user
|
|
*/
|
|
async getUserProgress(userId: string): Promise<BrainQuestionProgress[]> {
|
|
const progress: BrainQuestionProgress[] = [];
|
|
|
|
for await (
|
|
const entry of kvStore.list<BrainQuestionProgress>({
|
|
prefix: ["brain_question_progress", userId],
|
|
})
|
|
) {
|
|
if (entry.value) {
|
|
progress.push(entry.value);
|
|
}
|
|
}
|
|
|
|
return progress;
|
|
}
|
|
|
|
/**
|
|
* Get a specific progress record for a question (mode-agnostic)
|
|
*/
|
|
async getProgress(
|
|
userId: string,
|
|
questionId: string,
|
|
): Promise<BrainQuestionProgress | null> {
|
|
const id = generateBrainProgressId(userId, questionId);
|
|
const key = ["brain_question_progress", userId, id];
|
|
return await kvStore.get<BrainQuestionProgress>(key);
|
|
}
|
|
|
|
/**
|
|
* Get all questions due for review
|
|
*/
|
|
async getDueQuestions(userId: string): Promise<BrainQuestionProgress[]> {
|
|
const allProgress = await this.getUserProgress(userId);
|
|
return allProgress.filter((p) => isDue(p.next_due_at));
|
|
}
|
|
|
|
/**
|
|
* Record an answer and update progress with mode progression logic
|
|
*
|
|
* Mode progression rules:
|
|
* - When wasCorrectFirstTry is true and not in final mode: advance to next mode
|
|
* - When wasCorrectFirstTry is true and in final mode: mark as review-only
|
|
* - When wasCorrectFirstTry is false: stay in current mode, reset consecutive streak
|
|
*/
|
|
async recordAnswer(
|
|
userId: string,
|
|
questionId: string,
|
|
quizId: string,
|
|
wasCorrectFirstTry: boolean,
|
|
): Promise<BrainQuestionProgress> {
|
|
const id = generateBrainProgressId(userId, questionId);
|
|
const key = ["brain_question_progress", userId, id];
|
|
const now = Date.now();
|
|
|
|
// Get existing progress or create new
|
|
const existing = await kvStore.get<BrainQuestionProgress>(key);
|
|
|
|
// Determine current mode and index
|
|
let currentMode = existing?.current_mode ?? DEFAULT_QUIZ_MODE;
|
|
let modeIndex = existing?.mode_index ?? 0;
|
|
let consecutiveFirstTry = existing?.consecutive_first_try ?? 0;
|
|
let isReviewOnly = existing?.is_review_only ?? false;
|
|
let currentInterval = existing?.interval_days ?? 1;
|
|
const currentEase = existing?.ease_factor ?? 2.5;
|
|
|
|
// Mode progression logic
|
|
if (wasCorrectFirstTry) {
|
|
consecutiveFirstTry++;
|
|
|
|
// Advance to next mode if correct on first try
|
|
const nextMode = getNextMode(currentMode);
|
|
if (nextMode !== null) {
|
|
// Move to next harder mode
|
|
currentMode = nextMode;
|
|
modeIndex = getModeIndex(nextMode);
|
|
// Reset interval for the new mode (card needs to be reviewed soon in new mode)
|
|
currentInterval = 1;
|
|
} else {
|
|
// Already at final mode and correct on first try - mark as review-only
|
|
isReviewOnly = true;
|
|
// Push due date far out for review-only cards
|
|
currentInterval = REVIEW_ONLY_INTERVAL_DAYS;
|
|
}
|
|
} else {
|
|
// Incorrect: reset consecutive streak, stay in current mode
|
|
consecutiveFirstTry = 0;
|
|
// Don't reset mode - keep in current mode until they get it right
|
|
}
|
|
|
|
// Calculate new review schedule
|
|
const { nextDueAt, newInterval, newEaseFactor } = calculateNextReview(
|
|
wasCorrectFirstTry,
|
|
currentInterval,
|
|
currentEase,
|
|
);
|
|
|
|
// For review-only cards, use longer interval
|
|
const finalInterval = isReviewOnly
|
|
? Math.max(newInterval, REVIEW_ONLY_INTERVAL_DAYS)
|
|
: newInterval;
|
|
const finalNextDueAt = isReviewOnly
|
|
? now + (finalInterval * 24 * 60 * 60 * 1000)
|
|
: nextDueAt;
|
|
|
|
const progress: BrainQuestionProgress = {
|
|
id,
|
|
user_id: userId,
|
|
quiz_id: quizId,
|
|
question_id: questionId,
|
|
current_mode: currentMode,
|
|
mode_index: modeIndex,
|
|
attempts: (existing?.attempts ?? 0) + 1,
|
|
first_try_successes: (existing?.first_try_successes ?? 0) +
|
|
(wasCorrectFirstTry ? 1 : 0),
|
|
consecutive_first_try: consecutiveFirstTry,
|
|
last_result: wasCorrectFirstTry,
|
|
next_due_at: finalNextDueAt,
|
|
interval_days: finalInterval,
|
|
ease_factor: newEaseFactor,
|
|
is_review_only: isReviewOnly,
|
|
created_on: existing?.created_on ?? now,
|
|
updated_on: now,
|
|
};
|
|
|
|
await kvStore.set(key, progress);
|
|
return progress;
|
|
}
|
|
|
|
/**
|
|
* Build study queue from all user's quizzes
|
|
* Returns cards sorted by mode (easier modes first), then by due status
|
|
* Review-only cards are deprioritized unless there are too few active cards
|
|
*/
|
|
async buildStudyQueue(
|
|
userId: string,
|
|
quizConfigs: KvObj<QuizConfig>[],
|
|
_mode: string, // Deprecated - mode is now per-card based on progression
|
|
newCardsLimit: number = 10,
|
|
totalLimit: number = 50,
|
|
): Promise<BrainQueueItem[]> {
|
|
const now = Date.now();
|
|
// Get all existing progress for this user
|
|
const allProgress = await this.getUserProgress(userId);
|
|
const progressMap = new Map<string, BrainQuestionProgress>();
|
|
|
|
for (const p of allProgress) {
|
|
progressMap.set(p.question_id, p);
|
|
}
|
|
|
|
// Build queue items from all questions
|
|
const allItems: {
|
|
item: BrainQueueItem;
|
|
nextDueAt: number | null;
|
|
isNew: boolean; // Truly new (never seen)
|
|
isInProgression: boolean; // Has started but not completed all modes
|
|
modeIndex: number;
|
|
isReviewOnly: boolean;
|
|
isDue: boolean;
|
|
isReviewLongEnough: boolean;
|
|
}[] = [];
|
|
|
|
for (const kvObj of quizConfigs) {
|
|
if (!kvObj.value?.questions) continue;
|
|
|
|
for (const question of kvObj.value.questions) {
|
|
const progress = progressMap.get(question.id) ?? null;
|
|
// "Truly new" = never seen before (no progress record)
|
|
const isTrulyNew = progress === null;
|
|
// "In progression" = has started but hasn't completed all 6 modes yet
|
|
const isInProgression = progress !== null && !progress.is_review_only;
|
|
// For UI: "new" means either truly new or still in active progression
|
|
const isNew = isTrulyNew || isInProgression;
|
|
|
|
// Determine the mode for this card
|
|
const displayMode = progress?.current_mode ?? DEFAULT_QUIZ_MODE;
|
|
const modeIndex = progress?.mode_index ?? 0;
|
|
const isReviewOnly = progress?.is_review_only ?? false;
|
|
const nextDueAt = progress?.next_due_at ?? null;
|
|
const cardIsDue = isDue(nextDueAt);
|
|
const lastReviewedAt = progress?.updated_on ?? progress?.created_on ??
|
|
null;
|
|
const isReviewLongEnough = !isReviewOnly || lastReviewedAt === null
|
|
? true
|
|
: (now - lastReviewedAt) >=
|
|
(REVIEW_ONLY_MIN_DAYS_SINCE_LAST_REVIEW * 24 * 60 * 60 * 1000);
|
|
|
|
allItems.push({
|
|
item: {
|
|
question_id: question.id,
|
|
quiz_id: kvObj.value.quiz_config_id,
|
|
question_data: question,
|
|
progress,
|
|
is_new: isNew,
|
|
display_mode: displayMode,
|
|
mode_index: modeIndex,
|
|
is_review_only: isReviewOnly,
|
|
},
|
|
nextDueAt,
|
|
isNew: isTrulyNew, // For queue building, only count truly new cards toward newLimit
|
|
isInProgression,
|
|
modeIndex,
|
|
isReviewOnly,
|
|
isDue: cardIsDue,
|
|
isReviewLongEnough,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Separate cards into categories for the locked-set study behavior:
|
|
// 1. Cards currently in progression (started but not finished all 6 modes) - highest priority
|
|
// IMPORTANT: Do NOT gate on isDue - active-learning cards should remain eligible
|
|
// even if next_due_at is in the future, so the user can complete all 6 modes in one session
|
|
// 2. Truly new cards (never seen) - these fill the active set up to newLimit
|
|
// 3. Review-only cards (completed all modes, now in spaced repetition) - only if due
|
|
const inProgressionCards = allItems.filter(
|
|
(item) => item.isInProgression, // No isDue check - always include in-progression cards
|
|
);
|
|
const trulyNewCards = allItems.filter((item) => item.isNew);
|
|
const reviewOnlyCards = allItems.filter(
|
|
(item) => item.isReviewOnly && item.isDue && item.isReviewLongEnough, // Review-only respects due and minimum time since last review
|
|
);
|
|
|
|
// Sort each category by mode index (easier modes first for learning progression)
|
|
const sortByModeAndDue = (
|
|
a: typeof allItems[0],
|
|
b: typeof allItems[0],
|
|
) => {
|
|
// Primary: mode index (lower = easier = first)
|
|
if (a.modeIndex !== b.modeIndex) {
|
|
return a.modeIndex - b.modeIndex;
|
|
}
|
|
// Secondary: due status (more overdue first)
|
|
const aDue = a.nextDueAt ?? Date.now();
|
|
const bDue = b.nextDueAt ?? Date.now();
|
|
return aDue - bDue;
|
|
};
|
|
|
|
inProgressionCards.sort(sortByModeAndDue);
|
|
trulyNewCards.sort(sortByModeAndDue);
|
|
reviewOnlyCards.sort(sortByModeAndDue);
|
|
|
|
// Randomize the pool of new cards so large queue requests don't return the same order
|
|
// Keep in-progression cards sorted/stable so users can reliably finish what they've started.
|
|
shuffleInPlace(trulyNewCards);
|
|
shuffleInPlace(reviewOnlyCards);
|
|
|
|
// Build final queue using locked-set behavior:
|
|
// - First, include all cards currently in progression (they're part of the active set)
|
|
// - Then, fill up to newCardsLimit with truly new cards
|
|
// - Review-only cards are only added if specifically requested (not mixed into active set)
|
|
const queue: BrainQueueItem[] = [];
|
|
|
|
// 1. Add all cards currently in progression (these are the "active set" that must be completed)
|
|
for (const { item } of inProgressionCards) {
|
|
if (queue.length >= totalLimit) break;
|
|
queue.push(item);
|
|
}
|
|
|
|
// 2. Calculate how many slots are available for new cards
|
|
// The active set should be up to newCardsLimit (default 20)
|
|
const activeSetCount = queue.length;
|
|
const slotsForNew = Math.max(0, newCardsLimit - activeSetCount);
|
|
|
|
// 3. Add truly new cards to fill the active set up to the limit
|
|
let newCardsAdded = 0;
|
|
for (const { item } of trulyNewCards) {
|
|
if (queue.length >= totalLimit || newCardsAdded >= slotsForNew) break;
|
|
queue.push(item);
|
|
newCardsAdded++;
|
|
}
|
|
|
|
// 4. Only add review-only cards if there are no active new/progression cards
|
|
// This ensures the user focuses on completing the active set before reviews
|
|
if (queue.length === 0) {
|
|
for (const { item } of reviewOnlyCards) {
|
|
if (queue.length >= totalLimit) break;
|
|
queue.push(item);
|
|
}
|
|
}
|
|
|
|
return queue;
|
|
}
|
|
|
|
/**
|
|
* Get count of questions due for review
|
|
*/
|
|
async getDueCount(userId: string, _mode?: string): Promise<number> {
|
|
const allProgress = await this.getUserProgress(userId);
|
|
// Mode parameter is deprecated - count all due cards
|
|
return allProgress.filter((p) => isDue(p.next_due_at)).length;
|
|
}
|
|
|
|
/**
|
|
* Get statistics for a user
|
|
*/
|
|
async getStats(userId: string, _mode?: string): Promise<{
|
|
totalReviewed: number;
|
|
totalCorrect: number;
|
|
dueNow: number;
|
|
accuracy: number;
|
|
reviewOnlyCount: number;
|
|
modeDistribution: Record<string, number>;
|
|
}> {
|
|
const allProgress = await this.getUserProgress(userId);
|
|
|
|
const totalReviewed = allProgress.reduce(
|
|
(sum, p) => sum + (p.attempts ?? 0),
|
|
0,
|
|
);
|
|
const totalCorrect = allProgress.reduce(
|
|
(sum, p) => sum + (p.first_try_successes ?? 0),
|
|
0,
|
|
);
|
|
const dueNow = allProgress.filter((p) => isDue(p.next_due_at)).length;
|
|
const accuracy = totalReviewed > 0
|
|
? (totalCorrect / totalReviewed) * 100
|
|
: 0;
|
|
const reviewOnlyCount = allProgress.filter((p) => p.is_review_only).length;
|
|
|
|
// Count cards by current mode
|
|
const modeDistribution: Record<string, number> = {};
|
|
for (const mode of QUIZ_MODES) {
|
|
modeDistribution[mode] = 0;
|
|
}
|
|
for (const p of allProgress) {
|
|
const mode = p.current_mode ?? DEFAULT_QUIZ_MODE;
|
|
modeDistribution[mode] = (modeDistribution[mode] ?? 0) + 1;
|
|
}
|
|
|
|
return {
|
|
totalReviewed,
|
|
totalCorrect,
|
|
dueNow,
|
|
accuracy: Math.round(accuracy * 10) / 10,
|
|
reviewOnlyCount,
|
|
modeDistribution,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Reset progress for a specific question
|
|
*/
|
|
async resetQuestionProgress(
|
|
userId: string,
|
|
questionId: string,
|
|
): Promise<void> {
|
|
const id = generateBrainProgressId(userId, questionId);
|
|
const key = ["brain_question_progress", userId, id];
|
|
await kvStore.delete(key);
|
|
}
|
|
|
|
/**
|
|
* Reset all progress for a quiz
|
|
*/
|
|
async resetQuizProgress(userId: string, quizId: string): Promise<void> {
|
|
const allProgress = await this.getUserProgress(userId);
|
|
for (const p of allProgress) {
|
|
if (p.quiz_id === quizId) {
|
|
const key = ["brain_question_progress", userId, p.id];
|
|
await kvStore.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reset all progress for a user
|
|
*/
|
|
async resetAllProgress(userId: string): Promise<void> {
|
|
const allProgress = await this.getUserProgress(userId);
|
|
for (const p of allProgress) {
|
|
const key = ["brain_question_progress", userId, p.id];
|
|
await kvStore.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
export const brainDataClient = new BrainDataClient();
|