diff options
author | polwex <polwex@sortug.com> | 2025-05-29 14:08:02 +0700 |
---|---|---|
committer | polwex <polwex@sortug.com> | 2025-05-29 14:08:02 +0700 |
commit | f243847216279cbd43879de8b5ef6dcceb3a2f1d (patch) | |
tree | 1e0be878f164d327762c7bc54f37077d9410dafe | |
parent | 4ed3994fb0f6a2a09eb6ac433a62daee2fc01686 (diff) |
lets see
-rw-r--r-- | CLAUDE.md | 90 | ||||
-rw-r--r-- | src/actions/srs.ts | 105 | ||||
-rw-r--r-- | src/components/Flashcard/StudyCard.tsx | 265 | ||||
-rw-r--r-- | src/components/Flashcard/StudySession.tsx | 229 | ||||
-rw-r--r-- | src/components/Flashcard/cards.css | 55 | ||||
-rw-r--r-- | src/lib/services/srs_study.ts | 552 | ||||
-rw-r--r-- | src/pages/lessons.tsx | 126 | ||||
-rw-r--r-- | src/pages/study.tsx | 128 |
8 files changed, 1550 insertions, 0 deletions
diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2b82961 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,90 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a language learning application built with React, Waku, and Bun. It focuses on Thai language learning with features for: + +- Flashcard-based study using Spaced Repetition System (SRS) +- Detailed phonetic analysis and syllable breakdown +- Tone pattern visualization and practice +- User progress tracking +- Lessons and organized learning content + +## Commands + +### Development + +```bash +# Start the development server +bun run dev + +# Build the project +bun run build + +# Start the production server +bun run start +``` + +## Architecture + +### Framework and Runtime + +- **Waku**: Server-side rendering (SSR) framework +- **React**: UI framework (v19.1) +- **Bun**: JavaScript runtime +- **TypeScript**: Type system + +### Database + +The project uses SQLite databases (managed in `src/lib/db/`) with schemas for: + +- User accounts and progress tracking +- Language expressions and translations +- Syllable breakdowns and phonetic information +- Flashcards and lessons +- Spaced repetition data + +### Key Components + +1. **Server Actions** (`src/actions/`) + - Server-side functions marked with `"use server";` + - Handle database operations and server-side logic + - Examples: authentication, database operations, language processing + +2. **Client Components** (`src/components/`) + - React components marked with `"use client";` + - Import and call server actions + - Manage UI state and user interactions + +3. **SRS System** (`src/lib/services/srs*.ts`) + - Implements spaced repetition algorithms + - Tracks user progress and schedules reviews + - Multiple implementations for different tracking approaches + +4. **Language Processing** (`src/lib/lang/`, `src/zoom/`) + - Thai language syllable parsing and phonetic analysis + - Tone pattern visualization and analysis + - Text parsing and hierarchical display + +5. **Database Handlers** (`src/lib/db/`) + - Connection management + - CRUD operations for all entities + - Query functions for app features + +### Data Flow + +1. Server components fetch initial data +2. Client components render UI and handle user interactions +3. User actions trigger server actions +4. Server actions update the database +5. UI updates with new data from server actions + +### Key Files + +- `waku.config.ts`: Waku framework configuration +- `src/lib/db/schema.sql`: Main database schema +- `src/lib/db/prosodyschema.sql`: Phonetic analysis schema +- `src/lib/services/srs_streamlined.ts`: Main SRS implementation +- `src/actions/`: Server actions for various features
\ No newline at end of file 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 ( + <div className="text-gray-500 text-sm mt-2"> + /{card.expression.ipa[0].ipa}/ + </div> + ); + } + return null; + }; + + // Render senses/meanings if available + const renderSenses = () => { + if (card.expression.senses && card.expression.senses.length > 0) { + return ( + <div className="mt-4"> + {card.expression.senses.map((sense, index) => ( + <div key={index} className="mb-3"> + {sense.pos && <span className="text-xs font-medium text-blue-600 mr-2">{sense.pos}</span>} + {sense.senses && sense.senses.map((subsense, i) => ( + <div key={i} className="mt-1"> + {subsense.glosses && subsense.glosses.map((gloss, j) => ( + <div key={j} className="text-sm">{j+1}. {gloss}</div> + ))} + </div> + ))} + </div> + ))} + </div> + ); + } + return null; + }; + + // Show bookmarked status if applicable + const renderBookmarked = () => { + if (card.expression.isBookmarked) { + return <div className="absolute top-2 right-2 text-yellow-500">★</div>; + } + return null; + }; + + return ( + <div className="flex flex-col items-center"> + <div className={cn("flashcard-container", { flipped: isFlipped })} onClick={flipCard}> + <div className="flashcard"> + {/* Front of card */} + <div className="flashcard-front"> + <Card className="w-full h-full flex flex-col justify-center items-center p-6 relative"> + {renderBookmarked()} + <div className="text-2xl font-bold">{card.expression.spelling}</div> + {!isFlipped && renderIPA()} + <div className="mt-4 text-lg">{formatCardContent(card.text)}</div> + {card.note && <div className="mt-2 text-sm text-gray-500">{card.note}</div>} + {!isFlipped && ( + <div className="mt-6 text-sm text-gray-400"> + Click to flip + </div> + )} + </Card> + </div> + + {/* Back of card */} + <div className="flashcard-back"> + <Card className="w-full h-full flex flex-col justify-between p-6 relative"> + {renderBookmarked()} + <div> + <div className="text-2xl font-bold">{card.expression.spelling}</div> + {renderIPA()} + <div className="mt-4 text-lg">{formatCardContent(card.text, true)}</div> + {card.note && <div className="mt-2 text-sm text-gray-500">{card.note}</div>} + {renderSenses()} + </div> + + <div className="flex flex-col mt-6"> + <div className="text-sm text-gray-500 mb-2"> + How well did you remember this? + </div> + <div className="flex justify-between gap-2"> + <Button + variant="destructive" + onClick={() => handleGrade(false)} + disabled={isSubmitting} + className="flex-1" + > + Again + </Button> + <Button + variant="default" + onClick={() => handleGrade(true)} + disabled={isSubmitting} + className="flex-1" + > + Good + </Button> + </div> + + {/* Optional: Detailed grading */} + <div className="grid grid-cols-4 gap-2 mt-3"> + <Button + variant="outline" + size="sm" + onClick={() => handleDetailedGrade(0.2)} + disabled={isSubmitting} + className="text-red-500" + > + Forgot + </Button> + <Button + variant="outline" + size="sm" + onClick={() => handleDetailedGrade(0.6)} + disabled={isSubmitting} + className="text-orange-500" + > + Hard + </Button> + <Button + variant="outline" + size="sm" + onClick={() => handleDetailedGrade(0.8)} + disabled={isSubmitting} + className="text-green-500" + > + Good + </Button> + <Button + variant="outline" + size="sm" + onClick={() => handleDetailedGrade(1.0)} + disabled={isSubmitting} + className="text-blue-500" + > + Easy + </Button> + </div> + </div> + </Card> + </div> + </div> + </div> + + {/* Progress bar */} + <div className="w-full mt-4"> + <Progress value={getProgressPercentage()} className="h-2" /> + <div className="flex justify-between text-xs text-gray-500 mt-1"> + <span>Interval: {card.progress.interval} days</span> + <span>Ease: {card.progress.easeFactor.toFixed(1)}</span> + </div> + </div> + + {/* Skip button */} + {onSkip && ( + <Button + variant="ghost" + onClick={onSkip} + className="mt-4" + disabled={isSubmitting} + > + Skip + </Button> + )} + </div> + ); +}
\ 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<DeckResponse | null>(initialData || null); + const [currentCardIndex, setCurrentCardIndex] = useState(0); + const [reviewedCards, setReviewedCards] = useState<CardResponse[]>([]); + const [isLoading, setIsLoading] = useState(!initialData); + const [isCompleted, setIsCompleted] = useState(false); + const [stats, setStats] = useState<any>(null); + const [error, setError] = useState<string | null>(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 ( + <div className="w-full max-w-3xl mx-auto p-4"> + <Card className="p-6"> + <div className="space-y-4"> + <Skeleton className="h-8 w-1/2" /> + <Skeleton className="h-[400px] w-full" /> + <div className="flex justify-between"> + <Skeleton className="h-10 w-24" /> + <Skeleton className="h-10 w-24" /> + </div> + </div> + </Card> + </div> + ); + } + + // Render error state + if (error) { + return ( + <div className="w-full max-w-3xl mx-auto p-4"> + <Card className="p-6 text-center"> + <div className="text-red-500 mb-4">{error}</div> + <Button onClick={loadDeck}>Retry</Button> + </Card> + </div> + ); + } + + // Render completion state + if (isCompleted || !getCurrentCard()) { + return ( + <div className="w-full max-w-3xl mx-auto p-4"> + <Card className="p-6"> + <div className="text-center"> + <h2 className="text-2xl font-bold mb-4">Study Session Completed!</h2> + <div className="mb-6"> + <p className="text-lg">You've reviewed {reviewedCards.length} cards.</p> + {stats && ( + <div className="mt-4 text-sm text-gray-600"> + <p>Total cards: {stats.totalCards}</p> + <p>Mastered cards: {stats.masteredCards}</p> + <p>Due cards remaining: {stats.dueCards}</p> + </div> + )} + </div> + <div className="flex justify-center gap-4"> + <Button onClick={handleRestart}>Start New Session</Button> + <Button variant="outline" onClick={() => window.history.back()}> + Back to Lessons + </Button> + </div> + </div> + </Card> + </div> + ); + } + + // Render study session + return ( + <div className="w-full max-w-3xl mx-auto p-4"> + <div className="mb-6"> + <div className="flex justify-between items-center mb-2"> + <h2 className="text-xl font-bold"> + {deckData?.lesson.name} + </h2> + <div className="text-sm text-gray-500"> + {reviewedCards.length} / {deckData?.cards.length} cards + </div> + </div> + <Progress value={getCompletionPercentage()} className="h-2" /> + </div> + + <StudyCard + card={getCurrentCard()!} + userId={userId} + onComplete={handleCardComplete} + onSkip={handleSkip} + /> + + <div className="mt-6 flex justify-between"> + <Button variant="ghost" onClick={() => window.history.back()}> + Exit + </Button> + <Button variant="outline" onClick={handleSkip}> + Skip + </Button> + </div> + + {stats && ( + <div className="mt-8 p-4 bg-gray-50 rounded-lg"> + <h3 className="font-medium mb-2">Your Progress</h3> + <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> + <div className="text-center"> + <div className="text-2xl font-bold">{stats.totalCards}</div> + <div className="text-xs text-gray-500">Total Cards</div> + </div> + <div className="text-center"> + <div className="text-2xl font-bold">{stats.masteredCards}</div> + <div className="text-xs text-gray-500">Mastered</div> + </div> + <div className="text-center"> + <div className="text-2xl font-bold">{stats.dueCards}</div> + <div className="text-xs text-gray-500">Due Today</div> + </div> + <div className="text-center"> + <div className="text-2xl font-bold">{stats.streakDays}</div> + <div className="text-xs text-gray-500">Day Streak</div> + </div> + </div> + </div> + )} + </div> + ); +}
\ 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 ( + <div className="container mx-auto py-8"> + <Card className="p-6 text-center"> + <h1 className="text-2xl font-bold mb-4">Login Required</h1> + <p className="mb-4">You need to be logged in to view your lessons.</p> + <Button asChild> + <a href="/login">Login</a> + </Button> + </Card> + </div> + ); + } + + // Get user lessons data + let lessons; + try { + lessons = await getUserLessons(userId); + } catch (error) { + console.error("Error fetching lessons:", error); + } + + return ( + <div className="container mx-auto py-8"> + <div className="flex justify-between items-center mb-6"> + <h1 className="text-3xl font-bold">Your Lessons</h1> + <Button asChild> + <a href="/">Back to Home</a> + </Button> + </div> + + {!lessons || lessons.length === 0 ? ( + <NoLessonsFound /> + ) : ( + <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> + {lessons.map((lesson) => ( + <LessonCard key={lesson.id} lesson={lesson} userId={userId} /> + ))} + </div> + )} + </div> + ); +} + +// Component to display when no lessons are found +function NoLessonsFound() { + return ( + <Card className="p-6 text-center"> + <h2 className="text-xl font-semibold mb-3">No Lessons Found</h2> + <p className="text-gray-500 mb-4"> + You don't have any lessons available yet. + </p> + </Card> + ); +} + +// 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 ( + <Card + className="overflow-hidden transition-shadow duration-300 hover:shadow-lg" + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + <div className="p-6"> + <h3 className="text-xl font-bold mb-2">{lesson.name}</h3> + <p className="text-gray-500 text-sm mb-4 line-clamp-2"> + {lesson.description || "No description available."} + </p> + + <div className="flex items-center justify-between mb-4"> + <div className="text-sm"> + <span className="font-medium">{lesson.masteredCards}</span> + <span className="text-gray-500"> / {lesson.totalCards} cards</span> + </div> + <div className="text-sm"> + <span className="font-medium text-amber-600"> + {lesson.dueCards} due + </span> + </div> + </div> + + <div className="mb-6"> + <Progress + value={progressPercentage} + className={`h-2 ${getProgressColor(progressPercentage)}`} + /> + </div> + + <div className="flex gap-3"> + <Button asChild className="flex-1"> + <a href={`/study?lessonId=${lesson.id}`}>Study Now</a> + </Button> + <Button variant="outline" asChild className="flex-1"> + <a href={`/lesson/${lesson.id}`}>View Details</a> + </Button> + </div> + </div> + </Card> + ); +}
\ 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 ( + <div className="container mx-auto py-8"> + <Card className="p-6 text-center"> + <h1 className="text-2xl font-bold mb-4">Login Required</h1> + <p className="mb-4">You need to be logged in to use the study session feature.</p> + <Button asChild> + <a href="/login">Login</a> + </Button> + </Card> + </div> + ); + } + + const lessonId = searchParams.lessonId ? parseInt(searchParams.lessonId, 10) : null; + + // If no lesson ID provided, show lesson selector + if (!lessonId) { + return <LessonSelector userId={userId} />; + } + + // 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 ( + <div className="container mx-auto py-8"> + <StudySession + userId={userId} + lessonId={lessonId} + initialData={initialData && !('error' in initialData) ? initialData : undefined} + /> + </div> + ); +} + +// Client component for selecting a lesson +function LessonSelector({ userId }: { userId: number }) { + const [lessonId, setLessonId] = useState<string>(""); + + return ( + <div className="container mx-auto py-8"> + <Card className="p-6 max-w-md mx-auto"> + <h1 className="text-2xl font-bold mb-6">Start Study Session</h1> + + <form action={`/study?lessonId=${lessonId}`}> + <div className="space-y-4"> + <div> + <Label htmlFor="lessonId">Lesson ID</Label> + <Input + id="lessonId" + value={lessonId} + onChange={(e) => setLessonId(e.target.value)} + placeholder="Enter lesson ID" + type="number" + required + /> + </div> + + <Button type="submit" className="w-full"> + Start Study Session + </Button> + </div> + </form> + + <div className="mt-6 pt-6 border-t border-gray-200"> + <h2 className="text-lg font-medium mb-3">Available Lessons</h2> + <p className="text-sm text-gray-500 mb-4"> + Here are some example lesson IDs you can use: + </p> + <div className="flex flex-wrap gap-2"> + <Button + variant="outline" + size="sm" + onClick={() => setLessonId("1")} + > + Lesson 1 + </Button> + <Button + variant="outline" + size="sm" + onClick={() => setLessonId("2")} + > + Lesson 2 + </Button> + <Button + variant="outline" + size="sm" + onClick={() => setLessonId("5")} + > + Lesson 5 + </Button> + </div> + + <div className="mt-4"> + <Button + variant="ghost" + size="sm" + asChild + className="text-blue-500" + > + <a href="/">Back to Home</a> + </Button> + </div> + </div> + </Card> + </div> + ); +}
\ No newline at end of file |