From f243847216279cbd43879de8b5ef6dcceb3a2f1d Mon Sep 17 00:00:00 2001 From: polwex Date: Thu, 29 May 2025 14:08:02 +0700 Subject: lets see --- src/actions/srs.ts | 105 ++++++ src/components/Flashcard/StudyCard.tsx | 265 ++++++++++++++ src/components/Flashcard/StudySession.tsx | 229 +++++++++++++ src/components/Flashcard/cards.css | 55 +++ src/lib/services/srs_study.ts | 552 ++++++++++++++++++++++++++++++ src/pages/lessons.tsx | 126 +++++++ src/pages/study.tsx | 128 +++++++ 7 files changed, 1460 insertions(+) create mode 100644 src/actions/srs.ts create mode 100644 src/components/Flashcard/StudyCard.tsx create mode 100644 src/components/Flashcard/StudySession.tsx create mode 100644 src/lib/services/srs_study.ts create mode 100644 src/pages/lessons.tsx create mode 100644 src/pages/study.tsx (limited to 'src') diff --git a/src/actions/srs.ts b/src/actions/srs.ts new file mode 100644 index 0000000..47e1655 --- /dev/null +++ b/src/actions/srs.ts @@ -0,0 +1,105 @@ +"use server"; + +import db from "@/lib/db"; +import { SRSStudyService, ReviewResult } from "@/lib/services/srs_study"; + +// Create an instance of the SRS service +const srsService = new SRSStudyService(db); + +/** + * Start a study session for a lesson + * @param userId User ID + * @param lessonId Lesson ID + * @param random Whether to randomize card order + * @returns Cards due for review or new cards + */ +export async function startStudySession(userId: number, lessonId: number, random: boolean = true) { + return srsService.startStudySession(userId, lessonId, random); +} + +/** + * Process a review result and update SRS parameters + * @param userId User ID + * @param cardId Card ID + * @param accuracy Recall accuracy (0-1) + * @param reviewTime Time taken to review in milliseconds + * @returns Updated card data + */ +export async function processReview( + userId: number, + cardId: number, + accuracy: number, + reviewTime: number = 5000 +) { + const reviewResult: ReviewResult = { + cardId, + accuracy, + reviewTime + }; + + return srsService.processReview(userId, reviewResult); +} + +/** + * Reset progress for a specific card + * @param userId User ID + * @param cardId Card ID + */ +export async function resetCardProgress(userId: number, cardId: number) { + srsService.resetProgress(userId, cardId); + return { ok: "Progress reset successfully" }; +} + +/** + * Get user study statistics + * @param userId User ID + * @returns Study statistics + */ +export async function getUserStudyStats(userId: number) { + return srsService.getUserStats(userId); +} + +/** + * Get lesson progress statistics + * @param userId User ID + * @param lessonId Lesson ID + * @returns Lesson progress statistics + */ +export async function getLessonProgress(userId: number, lessonId: number) { + return srsService.getLessonProgress(userId, lessonId); +} + +/** + * Get all lessons with progress information for a user + * @param userId User ID + * @returns Array of lessons with progress information + */ +export async function getUserLessons(userId: number) { + return srsService.getUserLessons(userId); +} + +/** + * Get new cards for a lesson that haven't been studied yet + * @param userId User ID + * @param lessonId Lesson ID + * @param random Whether to randomize card order + * @returns New cards for the lesson + */ +export async function getNewCards(userId: number, lessonId: number, random: boolean = true) { + return srsService.fetchNewCards(userId, lessonId, random); +} + +/** + * Create a simplified response object from review result + * Used for grading cards with Good/Again responses + * @param userId User ID + * @param cardId Card ID + * @param isCorrect Whether the answer was correct + * @returns Updated card data + */ +export async function gradeCard(userId: number, cardId: number, isCorrect: boolean) { + // Convert boolean to accuracy (0.2 for wrong, 1.0 for correct) + const accuracy = isCorrect ? 1.0 : 0.2; + + return processReview(userId, cardId, accuracy); +} \ No newline at end of file diff --git a/src/components/Flashcard/StudyCard.tsx b/src/components/Flashcard/StudyCard.tsx new file mode 100644 index 0000000..4e554b4 --- /dev/null +++ b/src/components/Flashcard/StudyCard.tsx @@ -0,0 +1,265 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { CardResponse } from "@/lib/types/cards"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { processReview, gradeCard } from "@/actions/srs"; +import "./cards.css"; + +interface StudyCardProps { + card: CardResponse; + userId: number; + onComplete: (newCard: CardResponse) => void; + onSkip?: () => void; +} + +export default function StudyCard({ card, userId, onComplete, onSkip }: StudyCardProps) { + const [isFlipped, setIsFlipped] = useState(false); + const [startTime, setStartTime] = useState(0); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Reset the timer when a new card is shown + useEffect(() => { + setIsFlipped(false); + setStartTime(Date.now()); + }, [card.id]); + + // Toggle card flip + const flipCard = () => { + if (!isFlipped) { + setIsFlipped(true); + } + }; + + // Calculate time spent on card in milliseconds + const getReviewTime = () => { + return Date.now() - startTime; + }; + + // Handle card grading (Good/Again) + const handleGrade = async (isCorrect: boolean) => { + if (isSubmitting) return; + + setIsSubmitting(true); + + try { + const result = await gradeCard(userId, card.id, isCorrect); + + if ('error' in result) { + console.error("Error grading card:", result.error); + } else { + onComplete(result as CardResponse); + } + } catch (error) { + console.error("Error processing review:", error); + } finally { + setIsSubmitting(false); + } + }; + + // Handle detailed grading with accuracy level + const handleDetailedGrade = async (accuracy: number) => { + if (isSubmitting) return; + + setIsSubmitting(true); + + try { + const reviewTime = getReviewTime(); + const result = await processReview(userId, card.id, accuracy, reviewTime); + + if ('error' in result) { + console.error("Error processing review:", result.error); + } else { + onComplete(result as CardResponse); + } + } catch (error) { + console.error("Error processing review:", error); + } finally { + setIsSubmitting(false); + } + }; + + // Calculate progress percentage for the card + const getProgressPercentage = () => { + const { interval, easeFactor } = card.progress; + // Assuming max interval is 365 days and max ease factor is 4.0 + const intervalProgress = Math.min(interval / 365, 1) * 70; // 70% weight to interval + const easeProgress = Math.min((easeFactor - 1) / 3, 1) * 30; // 30% weight to ease factor + return intervalProgress + easeProgress; + }; + + // Format content based on card type + const formatCardContent = (content: string, isBack: boolean = false) => { + // You can add more sophisticated formatting here based on card type + return content; + }; + + // Render IPA pronunciation if available + const renderIPA = () => { + if (card.expression.ipa && card.expression.ipa.length > 0) { + return ( +
+ /{card.expression.ipa[0].ipa}/ +
+ ); + } + return null; + }; + + // Render senses/meanings if available + const renderSenses = () => { + if (card.expression.senses && card.expression.senses.length > 0) { + return ( +
+ {card.expression.senses.map((sense, index) => ( +
+ {sense.pos && {sense.pos}} + {sense.senses && sense.senses.map((subsense, i) => ( +
+ {subsense.glosses && subsense.glosses.map((gloss, j) => ( +
{j+1}. {gloss}
+ ))} +
+ ))} +
+ ))} +
+ ); + } + return null; + }; + + // Show bookmarked status if applicable + const renderBookmarked = () => { + if (card.expression.isBookmarked) { + return
; + } + return null; + }; + + return ( +
+
+
+ {/* Front of card */} +
+ + {renderBookmarked()} +
{card.expression.spelling}
+ {!isFlipped && renderIPA()} +
{formatCardContent(card.text)}
+ {card.note &&
{card.note}
} + {!isFlipped && ( +
+ Click to flip +
+ )} +
+
+ + {/* Back of card */} +
+ + {renderBookmarked()} +
+
{card.expression.spelling}
+ {renderIPA()} +
{formatCardContent(card.text, true)}
+ {card.note &&
{card.note}
} + {renderSenses()} +
+ +
+
+ How well did you remember this? +
+
+ + +
+ + {/* Optional: Detailed grading */} +
+ + + + +
+
+
+
+
+
+ + {/* Progress bar */} +
+ +
+ Interval: {card.progress.interval} days + Ease: {card.progress.easeFactor.toFixed(1)} +
+
+ + {/* Skip button */} + {onSkip && ( + + )} +
+ ); +} \ No newline at end of file diff --git a/src/components/Flashcard/StudySession.tsx b/src/components/Flashcard/StudySession.tsx new file mode 100644 index 0000000..1f79e09 --- /dev/null +++ b/src/components/Flashcard/StudySession.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { DeckResponse, CardResponse } from "@/lib/types/cards"; +import { startStudySession, getUserStudyStats } from "@/actions/srs"; +import StudyCard from "./StudyCard"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; + +interface StudySessionProps { + userId: number; + lessonId: number; + initialData?: DeckResponse; +} + +export default function StudySession({ userId, lessonId, initialData }: StudySessionProps) { + const [deckData, setDeckData] = useState(initialData || null); + const [currentCardIndex, setCurrentCardIndex] = useState(0); + const [reviewedCards, setReviewedCards] = useState([]); + const [isLoading, setIsLoading] = useState(!initialData); + const [isCompleted, setIsCompleted] = useState(false); + const [stats, setStats] = useState(null); + const [error, setError] = useState(null); + + // Load the deck data if not provided + useEffect(() => { + if (!initialData) { + loadDeck(); + } + + // Load user stats + loadStats(); + }, []); + + // Load deck data + const loadDeck = async () => { + setIsLoading(true); + setError(null); + + try { + const result = await startStudySession(userId, lessonId, true); + + if ('error' in result) { + setError(result.error); + setDeckData(null); + } else { + setDeckData(result); + } + } catch (error) { + console.error("Error loading deck:", error); + setError("Failed to load study session. Please try again later."); + } finally { + setIsLoading(false); + } + }; + + // Load user stats + const loadStats = async () => { + try { + const userStats = await getUserStudyStats(userId); + setStats(userStats); + } catch (error) { + console.error("Error loading stats:", error); + } + }; + + // Handle card completion + const handleCardComplete = (updatedCard: CardResponse) => { + // Add to reviewed cards + setReviewedCards(prev => [...prev, updatedCard]); + + // Move to next card + if (deckData && currentCardIndex < deckData.cards.length - 1) { + setCurrentCardIndex(currentCardIndex + 1); + } else { + // End of deck + setIsCompleted(true); + } + + // Refresh stats + loadStats(); + }; + + // Skip current card + const handleSkip = () => { + if (deckData && currentCardIndex < deckData.cards.length - 1) { + setCurrentCardIndex(currentCardIndex + 1); + } + }; + + // Restart session + const handleRestart = () => { + setCurrentCardIndex(0); + setReviewedCards([]); + setIsCompleted(false); + loadDeck(); + }; + + // Calculate completion percentage + const getCompletionPercentage = () => { + if (!deckData) return 0; + return (reviewedCards.length / deckData.cards.length) * 100; + }; + + // Get current card + const getCurrentCard = (): CardResponse | null => { + if (!deckData || !deckData.cards || deckData.cards.length === 0) return null; + return deckData.cards[currentCardIndex]; + }; + + // Render loading state + if (isLoading) { + return ( +
+ +
+ + +
+ + +
+
+
+
+ ); + } + + // Render error state + if (error) { + return ( +
+ +
{error}
+ +
+
+ ); + } + + // Render completion state + if (isCompleted || !getCurrentCard()) { + return ( +
+ +
+

Study Session Completed!

+
+

You've reviewed {reviewedCards.length} cards.

+ {stats && ( +
+

Total cards: {stats.totalCards}

+

Mastered cards: {stats.masteredCards}

+

Due cards remaining: {stats.dueCards}

+
+ )} +
+
+ + +
+
+
+
+ ); + } + + // Render study session + return ( +
+
+
+

+ {deckData?.lesson.name} +

+
+ {reviewedCards.length} / {deckData?.cards.length} cards +
+
+ +
+ + + +
+ + +
+ + {stats && ( +
+

Your Progress

+
+
+
{stats.totalCards}
+
Total Cards
+
+
+
{stats.masteredCards}
+
Mastered
+
+
+
{stats.dueCards}
+
Due Today
+
+
+
{stats.streakDays}
+
Day Streak
+
+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/Flashcard/cards.css b/src/components/Flashcard/cards.css index 2f75ad6..2d80051 100644 --- a/src/components/Flashcard/cards.css +++ b/src/components/Flashcard/cards.css @@ -20,6 +20,61 @@ body { transform: rotateY(180deg); } +/* Flashcard styles */ +.flashcard-container { + perspective: 1000px; + width: 100%; + max-width: 600px; + height: 400px; + cursor: pointer; + margin: 0 auto; +} + +.flashcard { + position: relative; + width: 100%; + height: 100%; + transition: transform 0.6s; + transform-style: preserve-3d; + box-shadow: 0 4px 8px rgba(0,0,0,0.1); + border-radius: 0.5rem; +} + +.flipped .flashcard { + transform: rotateY(180deg); +} + +.flashcard-front, +.flashcard-back { + position: absolute; + width: 100%; + height: 100%; + -webkit-backface-visibility: hidden; + backface-visibility: hidden; + border-radius: 0.5rem; + overflow: hidden; +} + +.flashcard-front { + z-index: 2; +} + +.flashcard-back { + transform: rotateY(180deg); +} + +/* Card hover effect */ +.flashcard-container:hover .flashcard { + box-shadow: 0 8px 16px rgba(0,0,0,0.2); +} + +/* Responsive adjustments */ +@media (max-width: 640px) { + .flashcard-container { + height: 350px; + } +} + /* Slide animations */ @keyframes slide-in-right { from { 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 diff --git a/src/pages/lessons.tsx b/src/pages/lessons.tsx new file mode 100644 index 0000000..ef8aa49 --- /dev/null +++ b/src/pages/lessons.tsx @@ -0,0 +1,126 @@ +import { useState } from "react"; +import { getState } from "@/lib/db"; +import { getUserLessons, getLessonProgress } from "@/actions/srs"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; + +// This is a server component that gets the initial data +export default async function LessonsPage() { + const state = getState(null); + const userId = state.user?.id; + + // If not logged in, show login required message + if (!userId) { + return ( +
+ +

Login Required

+

You need to be logged in to view your lessons.

+ +
+
+ ); + } + + // Get user lessons data + let lessons; + try { + lessons = await getUserLessons(userId); + } catch (error) { + console.error("Error fetching lessons:", error); + } + + return ( +
+
+

Your Lessons

+ +
+ + {!lessons || lessons.length === 0 ? ( + + ) : ( +
+ {lessons.map((lesson) => ( + + ))} +
+ )} +
+ ); +} + +// Component to display when no lessons are found +function NoLessonsFound() { + return ( + +

No Lessons Found

+

+ You don't have any lessons available yet. +

+
+ ); +} + +// Component to display a lesson card +function LessonCard({ lesson, userId }: { lesson: any; userId: number }) { + const [isHovered, setIsHovered] = useState(false); + + // Calculate progress percentage + const progressPercentage = lesson.progress || 0; + + // Determine progress color + const getProgressColor = (percentage: number) => { + if (percentage >= 80) return "bg-green-500"; + if (percentage >= 50) return "bg-yellow-500"; + return "bg-blue-500"; + }; + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > +
+

{lesson.name}

+

+ {lesson.description || "No description available."} +

+ +
+
+ {lesson.masteredCards} + / {lesson.totalCards} cards +
+
+ + {lesson.dueCards} due + +
+
+ +
+ +
+ +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/pages/study.tsx b/src/pages/study.tsx new file mode 100644 index 0000000..db7dde7 --- /dev/null +++ b/src/pages/study.tsx @@ -0,0 +1,128 @@ +import { useState } from "react"; +import { getState } from "@/lib/db"; +import { startStudySession } from "@/actions/srs"; +import StudySession from "@/components/Flashcard/StudySession"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +// This is a server component that gets the initial data +export default async function StudyPage({ searchParams }: { searchParams: { lessonId?: string } }) { + const state = getState(null); + const userId = state.user?.id; + + // If not logged in, show login required message + if (!userId) { + return ( +
+ +

Login Required

+

You need to be logged in to use the study session feature.

+ +
+
+ ); + } + + const lessonId = searchParams.lessonId ? parseInt(searchParams.lessonId, 10) : null; + + // If no lesson ID provided, show lesson selector + if (!lessonId) { + return ; + } + + // Get initial data for the study session + let initialData; + try { + initialData = await startStudySession(userId, lessonId, true); + } catch (error) { + console.error("Error starting study session:", error); + } + + return ( +
+ +
+ ); +} + +// Client component for selecting a lesson +function LessonSelector({ userId }: { userId: number }) { + const [lessonId, setLessonId] = useState(""); + + return ( +
+ +

Start Study Session

+ +
+
+
+ + setLessonId(e.target.value)} + placeholder="Enter lesson ID" + type="number" + required + /> +
+ + +
+
+ +
+

Available Lessons

+

+ Here are some example lesson IDs you can use: +

+
+ + + +
+ +
+ +
+
+
+
+ ); +} \ No newline at end of file -- cgit v1.2.3