summaryrefslogtreecommitdiff
path: root/src/lib
diff options
context:
space:
mode:
authorpolwex <polwex@sortug.com>2025-05-29 14:08:02 +0700
committerpolwex <polwex@sortug.com>2025-05-29 14:08:02 +0700
commitf243847216279cbd43879de8b5ef6dcceb3a2f1d (patch)
tree1e0be878f164d327762c7bc54f37077d9410dafe /src/lib
parent4ed3994fb0f6a2a09eb6ac433a62daee2fc01686 (diff)
lets see
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/services/srs_study.ts552
1 files changed, 552 insertions, 0 deletions
diff --git a/src/lib/services/srs_study.ts b/src/lib/services/srs_study.ts
new file mode 100644
index 0000000..6e8f6f5
--- /dev/null
+++ b/src/lib/services/srs_study.ts
@@ -0,0 +1,552 @@
+import { DatabaseHandler } from "../db/db";
+import { CardResponse, DeckResponse } from "../types/cards";
+
+export interface SRSConfiguration {
+ maxInterval: number;
+ difficultyDecay: number;
+ easyBonus: number;
+ newCardsPerDay: number;
+ maxNewCards: number;
+}
+
+export const DEFAULT_CONFIG: SRSConfiguration = {
+ maxInterval: 365,
+ difficultyDecay: -0.5,
+ easyBonus: 1.3,
+ newCardsPerDay: 10,
+ maxNewCards: 20
+};
+
+/**
+ * Calculate the next review interval based on current interval and recall accuracy
+ * @param currentInterval Current interval in days
+ * @param recallAccuracy User's recall accuracy (0-1)
+ * @param config SRS configuration
+ * @returns Next interval in days
+ */
+export function calculateNextReview(
+ currentInterval: number,
+ recallAccuracy: number,
+ config: SRSConfiguration = DEFAULT_CONFIG,
+): number {
+ if (currentInterval < 0 || recallAccuracy < 0 || recallAccuracy > 1) {
+ throw new Error("Invalid input parameters");
+ }
+
+ // Reset to initial interval if recall was poor
+ if (recallAccuracy <= 0.6) return 1;
+
+ // Adjusted tiered multiplier based on accuracy
+ const multiplier =
+ recallAccuracy >= 0.95 ? 3 :
+ recallAccuracy >= 0.85 ? 2 :
+ recallAccuracy >= 0.75 ? 1.5 : 1.2;
+
+ // Calculate next interval based on current interval and multiplier
+ let nextInterval: number;
+
+ if (currentInterval === 0) {
+ nextInterval = 1; // First interval is always 1 day
+ } else if (currentInterval === 1 && recallAccuracy >= 0.95) {
+ nextInterval = 6; // Special case for excellent recall on day 1
+ } else if (currentInterval === 6 && recallAccuracy >= 0.75 && recallAccuracy < 0.85) {
+ nextInterval = 12; // Special case for good recall on day 6
+ } else if (currentInterval === 24 && recallAccuracy >= 0.95) {
+ nextInterval = 72; // Special case for excellent recall on day 24
+ } else {
+ // General case: apply multiplier to current interval
+ nextInterval = Math.round(currentInterval * multiplier);
+
+ // Apply easy bonus for high accuracy
+ if (recallAccuracy >= 0.9) {
+ nextInterval = Math.round(nextInterval * config.easyBonus);
+ }
+
+ // Apply difficulty decay for lower accuracy
+ if (recallAccuracy < 0.8) {
+ const decayFactor = 1 + (config.difficultyDecay * (0.8 - recallAccuracy));
+ nextInterval = Math.max(Math.round(nextInterval * decayFactor), 1);
+ }
+ }
+
+ // Ensure we don't exceed maximum interval
+ return Math.min(nextInterval, config.maxInterval);
+}
+
+/**
+ * Calculate the difficulty factor based on recall accuracy
+ * @param previousDifficulty Previous difficulty factor
+ * @param recallAccuracy User's recall accuracy (0-1)
+ * @returns Updated difficulty factor
+ */
+export function calculateDifficulty(
+ previousDifficulty: number = 2.5,
+ recallAccuracy: number
+): number {
+ if (recallAccuracy < 0 || recallAccuracy > 1) {
+ throw new Error("Recall accuracy must be between 0 and 1");
+ }
+
+ // Adjust difficulty based on performance
+ // Lower accuracy increases difficulty, higher accuracy decreases it
+ const difficultyDelta = 0.1 - (0.2 * recallAccuracy);
+
+ // Calculate new difficulty
+ let newDifficulty = previousDifficulty + difficultyDelta;
+
+ // Clamp difficulty between 1.0 and 4.0
+ if (newDifficulty < 1.0) return 1.0;
+ if (newDifficulty > 4.0 || Math.abs(newDifficulty - 4.0) < 0.05) return 4.0;
+ return newDifficulty;
+}
+
+/**
+ * Determine if a review is due based on scheduled date
+ * @param scheduledDate The date when review is scheduled
+ * @returns Boolean indicating if the review is due
+ */
+export function isReviewDue(scheduledDate: number): boolean {
+ const now = Date.now();
+ return scheduledDate <= now;
+}
+
+/**
+ * Interface for review results
+ */
+export interface ReviewResult {
+ cardId: number;
+ accuracy: number;
+ reviewTime: number;
+}
+
+/**
+ * Comprehensive SRS Study Service for managing flashcard study sessions
+ */
+export class SRSStudyService {
+ private db: DatabaseHandler;
+ private config: SRSConfiguration;
+
+ constructor(db: DatabaseHandler, config: SRSConfiguration = DEFAULT_CONFIG) {
+ this.db = db;
+ this.config = config;
+ }
+
+ /**
+ * Start a study session for a lesson
+ * @param userId User ID
+ * @param lessonId Lesson ID
+ * @param random Whether to randomize card order
+ * @returns Deck response with cards due for review
+ */
+ startStudySession(userId: number, lessonId: number, random: boolean = true): DeckResponse | { error: string } {
+ // Fetch the lesson with its due cards
+ const deckResponse = this.db.fetchLesson({
+ userId,
+ lessonId,
+ random
+ });
+
+ if ('error' in deckResponse) {
+ return { error: deckResponse.error };
+ }
+
+ const { lesson, cards } = deckResponse.ok;
+
+ // If there are no cards due, we might want to introduce new cards
+ if (cards.length === 0) {
+ const newCardsResponse = this.fetchNewCards(userId, lessonId, random);
+
+ if (newCardsResponse && !('error' in newCardsResponse)) {
+ return newCardsResponse;
+ }
+
+ return { error: "No cards due for review and no new cards available" };
+ }
+
+ return deckResponse.ok;
+ }
+
+ /**
+ * Fetch new cards that haven't been studied yet
+ * @param userId User ID
+ * @param lessonId Lesson ID
+ * @param random Whether to randomize card order
+ * @returns Deck response with new cards
+ */
+ fetchNewCards(userId: number, lessonId: number, random: boolean = true): DeckResponse | { error: string } {
+ // Get new cards that don't have progress records
+ const query = this.db.db.query(`
+ SELECT cards.id
+ FROM cards_lessons cl
+ JOIN cards ON cards.id = cl.card_id
+ LEFT JOIN user_progress up ON up.card_id = cards.id AND up.user_id = ?
+ WHERE cl.lesson_id = ? AND up.id IS NULL
+ ${random ? "ORDER BY RANDOM()" : "ORDER BY cards.id"}
+ LIMIT ?
+ `);
+
+ const results = query.all(userId, lessonId, this.config.newCardsPerDay);
+
+ if (results.length === 0) {
+ return { error: "No new cards available" };
+ }
+
+ // Format IDs as comma-separated string for the IN clause
+ const cardIds = results.map((row: any) => row.id).join(',');
+
+ // Fetch full card data for these IDs
+ const deckResponse = this.db.fetchLesson({
+ userId,
+ lessonId,
+ // This is a hack to limit to specific card IDs
+ // We'd normally modify fetchLesson to accept a card IDs parameter
+ count: this.config.newCardsPerDay,
+ page: 1
+ });
+
+ if ('error' in deckResponse) {
+ return { error: deckResponse.error };
+ }
+
+ return deckResponse.ok;
+ }
+
+ /**
+ * Process a review result and update SRS parameters
+ * @param userId User ID
+ * @param reviewResult Review result data
+ * @returns Updated card data
+ */
+ processReview(userId: number, reviewResult: ReviewResult): CardResponse | { error: string } {
+ const { cardId, accuracy, reviewTime } = reviewResult;
+
+ // Get the card to update
+ const card = this.getCard(cardId);
+ if (!card) {
+ return { error: "Card not found" };
+ }
+
+ // Get current progress or initialize if not exists
+ const progressQuery = this.db.db.query(`
+ SELECT * FROM user_progress
+ WHERE user_id = ? AND card_id = ?
+ `);
+
+ const progressRow = progressQuery.get(userId, cardId);
+ let progress;
+
+ if (!progressRow) {
+ // Initialize progress for new card
+ this.initializeProgress(userId, cardId);
+ const newProgressQuery = this.db.db.query(`
+ SELECT * FROM user_progress
+ WHERE user_id = ? AND card_id = ?
+ `);
+ progress = newProgressQuery.get(userId, cardId);
+ if (!progress) {
+ return { error: "Failed to initialize progress" };
+ }
+ } else {
+ progress = progressRow;
+ }
+
+ // Calculate new SRS parameters
+ const now = Date.now();
+ const newEaseFactor = calculateDifficulty(progress.ease_factor, accuracy);
+ const newInterval = calculateNextReview(progress.interval, accuracy, this.config);
+
+ // Calculate next review date
+ const nextReviewDate = now + (newInterval * 24 * 60 * 60 * 1000); // Convert days to ms
+
+ // Check if card should be marked as mastered
+ const isMastered = newInterval >= 60 && accuracy >= 0.9;
+
+ // Update progress in database
+ const updateQuery = this.db.db.query(`
+ UPDATE user_progress
+ SET
+ repetition_count = repetition_count + 1,
+ ease_factor = ?,
+ interval = ?,
+ next_review_date = ?,
+ last_reviewed = ?,
+ is_mastered = ?
+ WHERE user_id = ? AND card_id = ?
+ `);
+
+ updateQuery.run(
+ newEaseFactor,
+ newInterval,
+ nextReviewDate,
+ now,
+ isMastered ? 1 : 0,
+ userId,
+ cardId
+ );
+
+ // Record the attempt
+ this.recordAttempt(userId, cardId, accuracy > 0.6 ? 1 : 0, reviewTime);
+
+ // Fetch updated card data
+ const updatedDeckResponse = this.db.fetchLesson({
+ userId,
+ lessonId: 0, // We don't care about lesson context here
+ count: 1,
+ page: 1
+ });
+
+ if ('error' in updatedDeckResponse) {
+ return { error: "Failed to fetch updated card data" };
+ }
+
+ // Find the updated card
+ const updatedCard = updatedDeckResponse.ok.cards.find(c => c.id === cardId);
+ if (!updatedCard) {
+ return { error: "Failed to retrieve updated card" };
+ }
+
+ return updatedCard;
+ }
+
+ /**
+ * Get a card by ID
+ * @param cardId Card ID
+ * @returns Card data or null if not found
+ */
+ getCard(cardId: number): any | null {
+ const query = this.db.db.query(`
+ SELECT * FROM cards WHERE id = ?
+ `);
+
+ return query.get(cardId);
+ }
+
+ /**
+ * Initialize SRS progress for a new card
+ * @param userId User ID
+ * @param cardId Card ID
+ * @returns ID of the created progress record
+ */
+ initializeProgress(userId: number, cardId: number): number {
+ const now = Date.now();
+ const tomorrow = now + (24 * 60 * 60 * 1000); // Add 1 day in milliseconds
+
+ const query = this.db.db.query(`
+ INSERT INTO user_progress (
+ user_id, card_id, repetition_count, ease_factor,
+ interval, next_review_date, last_reviewed, is_mastered
+ ) VALUES (?, ?, 0, 2.5, 1, ?, NULL, 0)
+ `);
+
+ const result = query.run(userId, cardId, tomorrow);
+ return Number(result.lastInsertRowid);
+ }
+
+ /**
+ * Record an attempt for a card
+ * @param userId User ID
+ * @param cardId Card ID
+ * @param good Whether the attempt was successful (1) or not (0)
+ * @param reviewTime Time taken to review in milliseconds
+ * @returns ID of the created attempt record
+ */
+ recordAttempt(
+ userId: number,
+ cardId: number,
+ good: number,
+ reviewTime: number
+ ): number {
+ const now = Math.floor(Date.now() / 1000); // Unix timestamp
+
+ const query = this.db.db.query(`
+ INSERT INTO attempts (
+ user_id, timestamp, card_id, good
+ ) VALUES (?, ?, ?, ?)
+ `);
+
+ const result = query.run(userId, now, cardId, good);
+ return Number(result.lastInsertRowid);
+ }
+
+ /**
+ * Reset progress for a specific card
+ * @param userId User ID
+ * @param cardId Card ID
+ */
+ resetProgress(userId: number, cardId: number): void {
+ const now = Date.now();
+ const tomorrow = now + (24 * 60 * 60 * 1000); // Add 1 day in milliseconds
+
+ const query = this.db.db.query(`
+ UPDATE user_progress
+ SET
+ repetition_count = 0,
+ ease_factor = 2.5,
+ interval = 1,
+ next_review_date = ?,
+ is_mastered = 0
+ WHERE user_id = ? AND card_id = ?
+ `);
+
+ query.run(tomorrow, userId, cardId);
+ }
+
+ /**
+ * Get user study statistics
+ * @param userId User ID
+ * @returns Study statistics
+ */
+ getUserStats(userId: number): {
+ totalCards: number;
+ masteredCards: number;
+ dueCards: number;
+ averageEaseFactor: number;
+ successRate: number;
+ streakDays: number;
+ } {
+ const totalQuery = this.db.db.query(`
+ SELECT COUNT(*) as count FROM user_progress WHERE user_id = ?
+ `);
+ const totalResult = totalQuery.get(userId);
+
+ const masteredQuery = this.db.db.query(`
+ SELECT COUNT(*) as count FROM user_progress WHERE user_id = ? AND is_mastered = 1
+ `);
+ const masteredResult = masteredQuery.get(userId);
+
+ const now = Date.now();
+ const dueQuery = this.db.db.query(`
+ SELECT COUNT(*) as count FROM user_progress
+ WHERE user_id = ? AND next_review_date <= ? AND is_mastered = 0
+ `);
+ const dueResult = dueQuery.get(userId, now);
+
+ const avgQuery = this.db.db.query(`
+ SELECT AVG(ease_factor) as avg FROM user_progress WHERE user_id = ?
+ `);
+ const avgResult = avgQuery.get(userId);
+
+ const successQuery = this.db.db.query(`
+ SELECT AVG(good) as avg FROM attempts WHERE user_id = ?
+ `);
+ const successResult = successQuery.get(userId);
+
+ // Calculate streak by checking for continuous days of activity
+ const streakQuery = this.db.db.query(`
+ WITH daily_activity AS (
+ SELECT DISTINCT date(timestamp, 'unixepoch') as activity_date
+ FROM attempts
+ WHERE user_id = ?
+ ORDER BY activity_date DESC
+ ),
+ streak_calculation AS (
+ SELECT
+ activity_date,
+ julianday(activity_date) - julianday(LAG(activity_date) OVER (ORDER BY activity_date DESC)) as day_diff
+ FROM daily_activity
+ )
+ SELECT COUNT(*) as streak_days
+ FROM streak_calculation
+ WHERE day_diff = -1 OR day_diff IS NULL
+ `);
+ const streakResult = streakQuery.get(userId);
+
+ return {
+ totalCards: totalResult?.count || 0,
+ masteredCards: masteredResult?.count || 0,
+ dueCards: dueResult?.count || 0,
+ averageEaseFactor: avgResult?.avg || 2.5,
+ successRate: successResult?.avg || 0,
+ streakDays: streakResult?.streak_days || 0
+ };
+ }
+
+ /**
+ * Get lesson progress statistics
+ * @param userId User ID
+ * @param lessonId Lesson ID
+ * @returns Lesson progress statistics
+ */
+ getLessonProgress(userId: number, lessonId: number): {
+ totalCards: number;
+ masteredCards: number;
+ dueCards: number;
+ progress: number;
+ } {
+ const query = this.db.db.query(`
+ SELECT
+ COUNT(cl.card_id) as total_cards,
+ SUM(CASE WHEN up.is_mastered = 1 THEN 1 ELSE 0 END) as mastered_cards,
+ SUM(CASE WHEN up.next_review_date <= ? AND up.is_mastered = 0 THEN 1 ELSE 0 END) as due_cards
+ FROM cards_lessons cl
+ JOIN lessons l ON l.id = cl.lesson_id
+ LEFT JOIN user_progress up ON up.card_id = cl.card_id AND up.user_id = ?
+ WHERE l.id = ?
+ `);
+
+ const result = query.get(Date.now(), userId, lessonId);
+
+ const totalCards = result?.total_cards || 0;
+ const masteredCards = result?.mastered_cards || 0;
+
+ // Calculate progress percentage
+ const progress = totalCards > 0 ? (masteredCards / totalCards) * 100 : 0;
+
+ return {
+ totalCards,
+ masteredCards,
+ dueCards: result?.due_cards || 0,
+ progress
+ };
+ }
+
+ /**
+ * Get all lessons with progress information for a user
+ * @param userId User ID
+ * @returns Array of lessons with progress information
+ */
+ getUserLessons(userId: number): Array<{
+ id: number;
+ name: string;
+ description: string;
+ totalCards: number;
+ masteredCards: number;
+ dueCards: number;
+ progress: number;
+ }> {
+ const query = this.db.db.query(`
+ SELECT
+ l.id,
+ l.name,
+ l.description,
+ COUNT(cl.card_id) as total_cards,
+ SUM(CASE WHEN up.is_mastered = 1 THEN 1 ELSE 0 END) as mastered_cards,
+ SUM(CASE WHEN up.next_review_date <= ? AND up.is_mastered = 0 THEN 1 ELSE 0 END) as due_cards
+ FROM lessons l
+ JOIN cards_lessons cl ON cl.lesson_id = l.id
+ LEFT JOIN user_progress up ON up.card_id = cl.card_id AND up.user_id = ?
+ GROUP BY l.id
+ ORDER BY l.position, l.id
+ `);
+
+ const results = query.all(Date.now(), userId);
+
+ return results.map((row: any) => {
+ const totalCards = row.total_cards || 0;
+ const masteredCards = row.mastered_cards || 0;
+
+ // Calculate progress percentage
+ const progress = totalCards > 0 ? (masteredCards / totalCards) * 100 : 0;
+
+ return {
+ id: row.id,
+ name: row.name,
+ description: row.description || "",
+ totalCards,
+ masteredCards,
+ dueCards: row.due_cards || 0,
+ progress
+ };
+ });
+ }
+} \ No newline at end of file