// 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(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 { const progress: BrainQuestionProgress[] = []; for await ( const entry of kvStore.list({ 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 { const id = generateBrainProgressId(userId, questionId); const key = ["brain_question_progress", userId, id]; return await kvStore.get(key); } /** * Get all questions due for review */ async getDueQuestions(userId: string): Promise { 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 { 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(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[], _mode: string, // Deprecated - mode is now per-card based on progression newCardsLimit: number = 10, totalLimit: number = 50, ): Promise { const now = Date.now(); // Get all existing progress for this user const allProgress = await this.getUserProgress(userId); const progressMap = new Map(); 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 { 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; }> { 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 = {}; 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 { 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 { 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 { 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();