Compare commits
3 Commits
8a03edbb88
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a30d7c4e5 | ||
|
|
de4e7c4c6e | ||
|
|
33377009d0 |
14
CLAUDE.md
14
CLAUDE.md
@@ -48,7 +48,19 @@ The specification defines 12 milestones (weeks). You MUST:
|
|||||||
- Ask for approval before starting each new milestone
|
- Ask for approval before starting each new milestone
|
||||||
- Report completion status for each milestone
|
- Report completion status for each milestone
|
||||||
|
|
||||||
**Current Milestone:** 1
|
**Current Milestone:** 10 (UI Polish)
|
||||||
|
|
||||||
|
**Completed Milestones:**
|
||||||
|
- ✅ Milestone 1: Foundation (Next.js, Prisma, Docker, NextAuth)
|
||||||
|
- ✅ Milestone 2: Authentication (Register, login, preferences)
|
||||||
|
- ✅ Milestones 3-4: Data Import (JSON/CSV parsers, admin UI, 14 tests)
|
||||||
|
- **Enhancement**: Database initialization system with auto-collection creation
|
||||||
|
- ✅ Milestone 5: Collections (CRUD, add/remove hanzi, 21 tests)
|
||||||
|
- ✅ Milestone 5: Hanzi Search (Search page, detail view, 16 tests)
|
||||||
|
- ✅ Milestone 6: SM-2 Algorithm (Core algorithm, 38 tests, 100% coverage)
|
||||||
|
- ✅ Milestones 7-8: Learning Interface (Session UI, SM-2 integration, keyboard shortcuts)
|
||||||
|
- **Enhancements**: English meaning display, two-stage card randomization
|
||||||
|
- ✅ Milestone 9: Dashboard & Progress (Statistics, charts, session history, Recharts integration)
|
||||||
|
|
||||||
### Rule 2: Database Schema is Fixed
|
### Rule 2: Database Schema is Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -335,57 +335,175 @@ simplified,traditional,pinyin,meaning,hsk_level,radical,frequency,pos,classifier
|
|||||||
|
|
||||||
## 9. Development Milestones
|
## 9. Development Milestones
|
||||||
|
|
||||||
### Week 1: Foundation
|
### Week 1: Foundation ✅ COMPLETE
|
||||||
- Setup Next.js 16 project
|
- ✅ Setup Next.js 16 project
|
||||||
- Configure Prisma + PostgreSQL
|
- ✅ Configure Prisma + PostgreSQL
|
||||||
- Setup Docker Compose
|
- ✅ Setup Docker Compose
|
||||||
- Create all data models
|
- ✅ Create all data models (18 models, 3 enums)
|
||||||
- Configure NextAuth.js
|
- ✅ Configure NextAuth.js
|
||||||
|
- ✅ Middleware for route protection
|
||||||
|
- ✅ All Prisma relations implemented
|
||||||
|
- ✅ Database migrations created
|
||||||
|
- ✅ Docker containers: nginx, app, postgres
|
||||||
|
- ✅ Build successful
|
||||||
|
|
||||||
### Week 2: Authentication
|
### Week 2: Authentication ✅ COMPLETE
|
||||||
- Registration/login pages
|
- ✅ Registration/login pages
|
||||||
- Middleware protection
|
- ✅ Middleware protection
|
||||||
- User preferences
|
- ✅ User preferences (cardsPerSession, characterDisplay, hideEnglish)
|
||||||
- Integration tests
|
- ✅ Integration tests (10 tests for auth, 8 tests for preferences)
|
||||||
|
- ✅ Server Actions: register, login, updatePreferences, getPreferences
|
||||||
|
- ✅ Zod validation for all inputs
|
||||||
|
- ✅ Password hashing with bcrypt
|
||||||
|
- ✅ Session management with NextAuth.js v5
|
||||||
|
- ✅ Settings page with preferences form
|
||||||
|
|
||||||
### Week 3-4: Data Import
|
### Week 3-4: Data Import ✅ COMPLETE
|
||||||
- Admin role middleware
|
- ✅ Admin role middleware
|
||||||
- HSK JSON parser
|
- ✅ HSK JSON parser (`src/lib/import/json-parser.ts`)
|
||||||
- CSV parser
|
- ✅ Support for complete-hsk-vocabulary format
|
||||||
- Import UI and actions
|
- ✅ All transcription types (pinyin, numeric, wade-giles, zhuyin, ipa)
|
||||||
- Test with real HSK data
|
- ✅ Multi-character hanzi support
|
||||||
|
- ✅ HSK level mapping (new-1 through old-6)
|
||||||
|
- ✅ CSV parser (`src/lib/import/csv-parser.ts`)
|
||||||
|
- ✅ Flexible column mapping
|
||||||
|
- ✅ Comma-separated multi-values
|
||||||
|
- ✅ Complete field validation
|
||||||
|
- ✅ Import UI and actions
|
||||||
|
- ✅ File upload and paste textarea
|
||||||
|
- ✅ Update existing or skip duplicates
|
||||||
|
- ✅ Detailed results with line-level errors
|
||||||
|
- ✅ Test with real HSK data
|
||||||
|
- ✅ 14 passing integration tests
|
||||||
|
- ✅ Admin import page at /admin/import
|
||||||
|
- ✅ **Enhancement**: Database initialization system
|
||||||
|
- ✅ `getInitializationFiles()` Server Action to list available files
|
||||||
|
- ✅ Multi-file selection for batch initialization
|
||||||
|
- ✅ SSE API endpoint (`/api/admin/initialize`) for long-running operations
|
||||||
|
- ✅ Real-time progress updates via Server-Sent Events
|
||||||
|
- ✅ Progress bar showing percent, current/total, and operation message
|
||||||
|
- ✅ Auto-create HSK level collections from hanzi level attributes
|
||||||
|
- ✅ Auto-populate collections with corresponding hanzi
|
||||||
|
- ✅ Optional clean data mode (delete all existing data)
|
||||||
|
- ✅ Admin initialization page at /admin/initialize with SSE integration
|
||||||
|
- ✅ No timeouts: processes complete.json (11K+ hanzi) smoothly
|
||||||
|
|
||||||
### Week 5: Collections
|
### Week 5: Collections ✅ COMPLETE
|
||||||
- Collections CRUD
|
- ✅ Collections CRUD (Server Actions in `src/actions/collections.ts`)
|
||||||
- Add/remove hanzi
|
- ✅ createCollection()
|
||||||
- Global HSK collections
|
- ✅ getUserCollections()
|
||||||
|
- ✅ getCollectionById()
|
||||||
|
- ✅ updateCollection()
|
||||||
|
- ✅ deleteCollection()
|
||||||
|
- ✅ Add/remove hanzi
|
||||||
|
- ✅ addHanziToCollection() with multi-select
|
||||||
|
- ✅ removeHanziFromCollection() with bulk support
|
||||||
|
- ✅ Search & select interface
|
||||||
|
- ✅ Paste list interface (comma, space, newline separated)
|
||||||
|
- ✅ Global HSK collections
|
||||||
|
- ✅ isPublic flag for admin-created collections
|
||||||
|
- ✅ Read-only for regular users
|
||||||
|
- ✅ Full control for admins
|
||||||
|
- ✅ 21 passing integration tests
|
||||||
|
- ✅ Pages: /collections, /collections/[id], /collections/new
|
||||||
|
- ✅ Order preservation with orderIndex
|
||||||
|
|
||||||
### Week 5: Hanzi Search
|
### Week 5: Hanzi Search ✅ COMPLETE
|
||||||
- Search page
|
- ✅ Search page (`/hanzi`)
|
||||||
- Filters (HSK level)
|
- ✅ Query input for simplified, traditional, pinyin, meaning
|
||||||
- Hanzi detail view
|
- ✅ Case-insensitive search
|
||||||
- Pagination
|
- ✅ Multi-character support
|
||||||
|
- ✅ Filters (HSK level)
|
||||||
|
- ✅ 12 HSK levels (new-1 through new-6, old-1 through old-6)
|
||||||
|
- ✅ Dynamic filtering on hskLevels relation
|
||||||
|
- ✅ Hanzi detail view (`/hanzi/[id]`)
|
||||||
|
- ✅ Large character display
|
||||||
|
- ✅ All forms with isDefault indicator
|
||||||
|
- ✅ All transcriptions grouped by type
|
||||||
|
- ✅ All meanings with language codes
|
||||||
|
- ✅ HSK level badges, parts of speech
|
||||||
|
- ✅ Classifiers, radical, frequency
|
||||||
|
- ✅ Add to collection button with modal
|
||||||
|
- ✅ Pagination
|
||||||
|
- ✅ 20 results per page
|
||||||
|
- ✅ hasMore indicator (limit+1 pattern)
|
||||||
|
- ✅ Previous/Next controls
|
||||||
|
- ✅ 16 passing integration tests
|
||||||
|
- ✅ Public access (no authentication required)
|
||||||
|
- ✅ Server Actions: searchHanzi(), getHanzi(), getHanziBySimplified()
|
||||||
|
|
||||||
### Week 6: SM-2 Algorithm
|
### Week 6: SM-2 Algorithm ✅ COMPLETE
|
||||||
- Implement algorithm
|
- ✅ Implement algorithm (`src/lib/learning/sm2.ts`)
|
||||||
- Card selection logic
|
- ✅ calculateCorrectAnswer() with exact formulas
|
||||||
- Progress tracking
|
- ✅ calculateIncorrectAnswer() with exact formulas
|
||||||
- Unit tests (90%+ coverage)
|
- ✅ Initial values: easeFactor=2.5, interval=1, consecutiveCorrect=0
|
||||||
|
- ✅ Correct answer intervals: 1, 6, then interval × easeFactor
|
||||||
|
- ✅ Incorrect answer: reset to 1 day, decrease easeFactor
|
||||||
|
- ✅ Card selection logic
|
||||||
|
- ✅ selectCardsForSession() with priority sorting
|
||||||
|
- ✅ Filter SUSPENDED cards
|
||||||
|
- ✅ Priority: HARD > NORMAL > EASY
|
||||||
|
- ✅ Sort: nextReviewDate ASC, incorrectCount DESC, consecutiveCorrect ASC
|
||||||
|
- ✅ Wrong answer generation
|
||||||
|
- ✅ generateWrongAnswers() selects 3 from same HSK level
|
||||||
|
- ✅ Fisher-Yates shuffle for randomization
|
||||||
|
- ✅ shuffleOptions() for answer position randomization
|
||||||
|
- ✅ Unit tests (38 tests, 100% coverage)
|
||||||
|
- ✅ Test all calculation formulas
|
||||||
|
- ✅ Test edge cases (minimum easeFactor, large intervals, etc.)
|
||||||
|
- ✅ Test card selection with all sorting criteria
|
||||||
|
- ✅ Test wrong answer generation
|
||||||
|
- ✅ 100% statement and line coverage
|
||||||
|
- ✅ 94.11% branch coverage (exceeds 90% requirement)
|
||||||
|
|
||||||
### Week 7-8: Learning Interface
|
### Week 7-8: Learning Interface ✅ COMPLETE
|
||||||
- Learning session pages
|
- ✅ Learning session pages
|
||||||
- Card component
|
- ✅ `/learn/[collectionId]` dynamic route
|
||||||
- Answer submission
|
- ✅ Large hanzi display (text-9xl)
|
||||||
- Feedback UI
|
- ✅ 4 pinyin options in 2x2 grid
|
||||||
- Session summary
|
- ✅ Progress bar with card count
|
||||||
- Keyboard shortcuts
|
- ✅ Card component
|
||||||
- E2E tests
|
- ✅ Auto-submit after selection
|
||||||
|
- ✅ Green/red feedback overlay
|
||||||
|
- ✅ English meaning display
|
||||||
|
- ✅ Answer submission
|
||||||
|
- ✅ `submitAnswer()` Server Action
|
||||||
|
- ✅ SM-2 progress updates
|
||||||
|
- ✅ Session review tracking
|
||||||
|
- ✅ Feedback UI
|
||||||
|
- ✅ Correct/incorrect indicators
|
||||||
|
- ✅ Correct answer display
|
||||||
|
- ✅ Vocabulary meaning reinforcement
|
||||||
|
- ✅ Session summary
|
||||||
|
- ✅ Total cards, accuracy, duration
|
||||||
|
- ✅ Correct/incorrect breakdown
|
||||||
|
- ✅ Keyboard shortcuts
|
||||||
|
- ✅ 1-4 for answer selection
|
||||||
|
- ✅ Space to continue
|
||||||
|
- ✅ Learning Server Actions (`src/actions/learning.ts`)
|
||||||
|
- ✅ `startLearningSession()` - Initialize with SM-2 card selection
|
||||||
|
- ✅ `submitAnswer()` - Record and update progress
|
||||||
|
- ✅ `endSession()` - Calculate summary stats
|
||||||
|
- ✅ `getDueCards()` - Count due cards
|
||||||
|
- ✅ `updateCardDifficulty()` - Manual difficulty override
|
||||||
|
- ✅ `removeFromLearning()` - Suspend card
|
||||||
|
- ✅ Two-stage card randomization
|
||||||
|
- ✅ Random tiebreaker during selection
|
||||||
|
- ✅ Final shuffle for presentation
|
||||||
|
- ✅ Navigation integration
|
||||||
|
- ✅ Dashboard "Start Learning" button
|
||||||
|
- ✅ Collection "Start Learning" button
|
||||||
|
- ✅ All 38 SM-2 algorithm tests passing (98.92% coverage)
|
||||||
|
|
||||||
### Week 9: Dashboard & Progress
|
### Week 9: Dashboard & Progress ✅
|
||||||
- Dashboard widgets
|
- ✅ Dashboard widgets with real statistics (due cards, total learned, daily goal, streak)
|
||||||
- Progress page
|
- ✅ Progress page with charts and session history
|
||||||
- Charts (Recharts)
|
- ✅ Charts (Recharts) - Daily activity bar chart, accuracy trend line chart
|
||||||
- Statistics calculations
|
- ✅ Statistics Server Actions (getStatistics, getUserProgress, getLearningSessions, getHanziProgress, resetHanziProgress)
|
||||||
|
- ✅ Recent activity section on dashboard
|
||||||
|
- ✅ Date range filtering (7/30/90/365 days)
|
||||||
|
- ✅ Session history table with complete details
|
||||||
|
- ✅ Navigation links to progress page
|
||||||
|
|
||||||
### Week 10: UI Polish
|
### Week 10: UI Polish
|
||||||
- Responsive layouts
|
- Responsive layouts
|
||||||
|
|||||||
265
README.md
265
README.md
@@ -112,19 +112,258 @@ Implement ALL models exactly as specified in the Prisma schema.
|
|||||||
|
|
||||||
## 📊 Development Milestones
|
## 📊 Development Milestones
|
||||||
|
|
||||||
| Week | Milestone | Focus |
|
| Week | Milestone | Focus | Status |
|
||||||
|------|-----------|-------|
|
|------|-----------|-------|--------|
|
||||||
| 1 | Foundation | Setup project, Docker, Prisma schema |
|
| 1 | Foundation | Setup project, Docker, Prisma schema | ✅ Complete |
|
||||||
| 2 | Authentication | User registration, login, preferences |
|
| 2 | Authentication | User registration, login, preferences | ✅ Complete |
|
||||||
| 3-4 | Data Import | Admin imports HSK data (JSON/CSV) |
|
| 3-4 | Data Import | Admin imports HSK data (JSON/CSV) | ✅ Complete |
|
||||||
| 5 | Collections | User collections + global HSK collections |
|
| 5 | Collections | User collections + global HSK collections | ✅ Complete |
|
||||||
| 5 | Hanzi Search | Search interface and detail views |
|
| 5 | Hanzi Search | Search interface and detail views | ✅ Complete |
|
||||||
| 6 | SM-2 Algorithm | Core learning algorithm + tests |
|
| 6 | SM-2 Algorithm | Core learning algorithm + tests | ✅ Complete |
|
||||||
| 7-8 | Learning UI | Learning session interface |
|
| 7-8 | Learning UI | Learning session interface | ✅ Complete |
|
||||||
| 9 | Dashboard | Progress tracking and visualizations |
|
| 9 | Dashboard | Progress tracking and visualizations | ✅ Complete |
|
||||||
| 10 | UI Polish | Responsive design, dark mode |
|
| 10 | UI Polish | Responsive design, dark mode | 🔄 Next |
|
||||||
| 11 | Testing & Docs | Complete test coverage |
|
| 11 | Testing & Docs | Complete test coverage | |
|
||||||
| 12 | Deployment | Production deployment + data import |
|
| 12 | Deployment | Production deployment + data import | |
|
||||||
|
|
||||||
|
### ✅ Milestone 3 Completed Features
|
||||||
|
|
||||||
|
**Data Import System:**
|
||||||
|
- ✅ HSK JSON parser supporting complete-hsk-vocabulary format
|
||||||
|
- ✅ CSV parser with flexible column mapping
|
||||||
|
- ✅ Admin import page with file upload and paste functionality
|
||||||
|
- ✅ Update existing entries or skip duplicates option
|
||||||
|
- ✅ Detailed import results with success/failure counts and line-level errors
|
||||||
|
- ✅ Format validation and error reporting
|
||||||
|
- ✅ Support for multi-character hanzi (words like 中国)
|
||||||
|
- ✅ All transcription types (pinyin, numeric, wade-giles, zhuyin, ipa)
|
||||||
|
- ✅ 14 passing integration tests for both JSON and CSV parsers
|
||||||
|
|
||||||
|
**Database Initialization System:**
|
||||||
|
- ✅ Multi-file selection for batch initialization
|
||||||
|
- ✅ Real-time progress updates via Server-Sent Events (SSE)
|
||||||
|
- ✅ Progress bar showing current operation and percentage
|
||||||
|
- ✅ Automatic HSK level collection creation
|
||||||
|
- ✅ Auto-populate collections with hanzi based on level attribute
|
||||||
|
- ✅ Optional clean data mode (delete all existing data before import)
|
||||||
|
- ✅ Comprehensive statistics: hanzi imported, collections created, items added
|
||||||
|
- ✅ Admin initialization page at /admin/initialize
|
||||||
|
- ✅ SSE API route at /api/admin/initialize for long-running operations
|
||||||
|
|
||||||
|
**Files Created:**
|
||||||
|
- `src/lib/import/json-parser.ts` - HSK JSON format parser
|
||||||
|
- `src/lib/import/csv-parser.ts` - CSV format parser
|
||||||
|
- `src/lib/import/json-parser.test.ts` - JSON parser tests
|
||||||
|
- `src/lib/import/csv-parser.test.ts` - CSV parser tests
|
||||||
|
- `src/actions/admin.ts` - Admin-only import and initialization actions
|
||||||
|
- `src/actions/admin.integration.test.ts` - Admin action tests
|
||||||
|
- `src/app/(admin)/admin/import/page.tsx` - Import UI
|
||||||
|
- `src/app/(admin)/admin/initialize/page.tsx` - Initialization UI with SSE progress
|
||||||
|
- `src/app/api/admin/initialize/route.ts` - SSE API endpoint for real-time progress
|
||||||
|
|
||||||
|
### ✅ Milestone 4 Completed Features
|
||||||
|
|
||||||
|
**Collections Management:**
|
||||||
|
- ✅ Complete CRUD operations for collections (create, read, update, delete)
|
||||||
|
- ✅ Global HSK collections (admin-created, read-only for users)
|
||||||
|
- ✅ User personal collections (full control)
|
||||||
|
- ✅ Add hanzi to collections via:
|
||||||
|
- Search & multi-select with checkboxes
|
||||||
|
- Paste list (comma, space, or newline separated)
|
||||||
|
- Create collection with hanzi list
|
||||||
|
- ✅ Remove hanzi (individual and bulk selection)
|
||||||
|
- ✅ Collection detail view with hanzi list
|
||||||
|
- ✅ Order preservation for added hanzi
|
||||||
|
- ✅ Duplicate detection and validation
|
||||||
|
- ✅ 21 passing integration tests
|
||||||
|
|
||||||
|
**Files Created:**
|
||||||
|
- `src/actions/collections.ts` - Collection Server Actions
|
||||||
|
- `src/actions/collections.integration.test.ts` - Complete test suite
|
||||||
|
- `src/app/(app)/collections/page.tsx` - Collections list page
|
||||||
|
- `src/app/(app)/collections/[id]/page.tsx` - Collection detail page
|
||||||
|
- `src/app/(app)/collections/new/page.tsx` - Create collection page
|
||||||
|
|
||||||
|
### ✅ Milestone 5 Completed Features
|
||||||
|
|
||||||
|
**Hanzi Search & Detail Views:**
|
||||||
|
- ✅ Public hanzi search (no authentication required)
|
||||||
|
- ✅ Search by simplified, traditional, pinyin, or meaning
|
||||||
|
- ✅ HSK level filtering (12 levels: new-1 through new-6, old-1 through old-6)
|
||||||
|
- ✅ Pagination with hasMore indicator (20 results per page)
|
||||||
|
- ✅ Comprehensive detail view showing:
|
||||||
|
- All forms (simplified, traditional with isDefault indicator)
|
||||||
|
- All transcriptions (pinyin, numeric, wade-giles, etc.)
|
||||||
|
- All meanings with language codes
|
||||||
|
- HSK level badges
|
||||||
|
- Parts of speech
|
||||||
|
- Classifiers, radical, frequency
|
||||||
|
- ✅ Add to collection from detail page
|
||||||
|
- ✅ 16 passing integration tests
|
||||||
|
|
||||||
|
**Files Created:**
|
||||||
|
- `src/actions/hanzi.ts` - Public hanzi search actions
|
||||||
|
- `src/app/(app)/hanzi/page.tsx` - Search page with filters
|
||||||
|
- `src/app/(app)/hanzi/[id]/page.tsx` - Detail page with all data
|
||||||
|
- `src/actions/hanzi.integration.test.ts` - Complete test suite
|
||||||
|
|
||||||
|
**Key Features:**
|
||||||
|
- searchHanzi(): Fuzzy search across simplified, traditional, pinyin, and meanings
|
||||||
|
- HSK level filtering for targeted vocabulary
|
||||||
|
- Pagination with hasMore indicator for infinite scroll support
|
||||||
|
- Complete hanzi data display including rare transcription types
|
||||||
|
- Direct integration with collections (add from detail page)
|
||||||
|
|
||||||
|
### ✅ Milestone 6 Completed Features
|
||||||
|
|
||||||
|
**SM-2 Algorithm Implementation:**
|
||||||
|
- ✅ Core SM-2 spaced repetition algorithm following SuperMemo specification
|
||||||
|
- ✅ Exact formulas for correct and incorrect answer calculations
|
||||||
|
- ✅ Initial values: easeFactor=2.5, interval=1, consecutiveCorrect=0
|
||||||
|
- ✅ Correct answer logic:
|
||||||
|
- First correct: interval = 1 day
|
||||||
|
- Second correct: interval = 6 days
|
||||||
|
- Third+ correct: interval = Math.round(interval × easeFactor)
|
||||||
|
- Increase easeFactor by 0.1 with each correct answer
|
||||||
|
- ✅ Incorrect answer logic:
|
||||||
|
- Reset interval to 1 day
|
||||||
|
- Reset consecutiveCorrect to 0
|
||||||
|
- Decrease easeFactor by 0.2 (minimum 1.3)
|
||||||
|
- Increment incorrectCount
|
||||||
|
- ✅ Card selection algorithm:
|
||||||
|
- Filter out SUSPENDED cards
|
||||||
|
- Select due cards (nextReviewDate ≤ now)
|
||||||
|
- Priority: HARD > NORMAL > EASY
|
||||||
|
- Sort by: nextReviewDate ASC, incorrectCount DESC, consecutiveCorrect ASC
|
||||||
|
- Limit to cardsPerSession
|
||||||
|
- ✅ Wrong answer generation with Fisher-Yates shuffle
|
||||||
|
- ✅ 38 passing unit tests with 100% statement and line coverage
|
||||||
|
- ✅ 94.11% branch coverage (exceeds 90% requirement)
|
||||||
|
|
||||||
|
**Files Created:**
|
||||||
|
- `src/lib/learning/sm2.ts` - Core algorithm implementation
|
||||||
|
- `src/lib/learning/sm2.test.ts` - Comprehensive unit tests
|
||||||
|
|
||||||
|
**Functions Implemented:**
|
||||||
|
- `calculateCorrectAnswer()` - Update progress for correct answers
|
||||||
|
- `calculateIncorrectAnswer()` - Update progress for incorrect answers
|
||||||
|
- `selectCardsForSession()` - Select due cards with priority sorting
|
||||||
|
- `generateWrongAnswers()` - Generate 3 incorrect options from same HSK level
|
||||||
|
- `shuffleOptions()` - Fisher-Yates shuffle for randomizing answer positions
|
||||||
|
|
||||||
|
### ✅ Milestone 7-8 Completed Features
|
||||||
|
|
||||||
|
**Learning Interface:**
|
||||||
|
- ✅ Learning session page (`/learn/[collectionId]`) with dynamic routing
|
||||||
|
- ✅ Large hanzi display (text-9xl) for easy reading
|
||||||
|
- ✅ 4 pinyin answer options in 2x2 grid layout
|
||||||
|
- ✅ Auto-submit after answer selection (100ms delay)
|
||||||
|
- ✅ Progress bar showing "Card X of Y" with percentage
|
||||||
|
- ✅ Green/red feedback overlay with checkmark/X icons
|
||||||
|
- ✅ Correct answer display for incorrect responses
|
||||||
|
- ✅ English meaning display after answer submission
|
||||||
|
- ✅ Session summary screen with statistics:
|
||||||
|
- Total cards, correct/incorrect counts
|
||||||
|
- Accuracy percentage
|
||||||
|
- Session duration in minutes
|
||||||
|
- ✅ Keyboard shortcuts:
|
||||||
|
- Press 1-4 to select answer options
|
||||||
|
- Press Space to continue after feedback
|
||||||
|
- ✅ Loading and error states
|
||||||
|
- ✅ Responsive mobile-first design
|
||||||
|
|
||||||
|
**Learning Server Actions:**
|
||||||
|
- ✅ `startLearningSession()` - Initialize session with card selection and answer generation
|
||||||
|
- ✅ `submitAnswer()` - Record answer and update SM-2 progress
|
||||||
|
- ✅ `endSession()` - Mark session complete and return summary
|
||||||
|
- ✅ `getDueCards()` - Count cards due today/this week
|
||||||
|
- ✅ `updateCardDifficulty()` - Manual difficulty override (EASY/MEDIUM/HARD/SUSPENDED)
|
||||||
|
- ✅ `removeFromLearning()` - Suspend card from learning
|
||||||
|
|
||||||
|
**SM-2 Integration:**
|
||||||
|
- ✅ Automatic progress tracking with SM-2 algorithm
|
||||||
|
- ✅ Due card selection with priority sorting
|
||||||
|
- ✅ New card introduction when insufficient due cards
|
||||||
|
- ✅ Two-stage card randomization:
|
||||||
|
- Random tiebreaker for equal-priority cards during selection
|
||||||
|
- Final shuffle of selected cards for presentation
|
||||||
|
- ✅ Wrong answer generation from same HSK level
|
||||||
|
- ✅ Session tracking in database (LearningSession, SessionReview)
|
||||||
|
|
||||||
|
**Navigation Integration:**
|
||||||
|
- ✅ "Start Learning" button on collection detail pages
|
||||||
|
- ✅ "Learn All" option on dashboard
|
||||||
|
- ✅ Routes: `/learn/all` and `/learn/[collectionId]`
|
||||||
|
|
||||||
|
**Files Created:**
|
||||||
|
- `src/actions/learning.ts` - Learning session Server Actions (700+ lines)
|
||||||
|
- `src/app/(app)/learn/[collectionId]/page.tsx` - Learning session UI (340+ lines)
|
||||||
|
|
||||||
|
**Enhancements:**
|
||||||
|
- ✅ English meaning display for vocabulary reinforcement
|
||||||
|
- ✅ Randomized card presentation to prevent demoralization
|
||||||
|
- ✅ All 38 SM-2 algorithm tests passing with 98.92% coverage
|
||||||
|
|
||||||
|
### ✅ Milestone 9 Completed Features
|
||||||
|
|
||||||
|
**Dashboard Enhancements:**
|
||||||
|
- ✅ Real-time statistics widgets replacing hardcoded zeros
|
||||||
|
- ✅ Due cards counter (now, today, this week)
|
||||||
|
- ✅ Total learned cards count
|
||||||
|
- ✅ Daily goal progress tracker (reviewed today / daily goal)
|
||||||
|
- ✅ Learning streak calculation (consecutive days with reviews)
|
||||||
|
- ✅ Recent activity section showing last 5 learning sessions
|
||||||
|
- ✅ Session cards with accuracy percentages and collection names
|
||||||
|
- ✅ Navigation link to progress page
|
||||||
|
|
||||||
|
**Progress Page:**
|
||||||
|
- ✅ Comprehensive progress page at `/progress`
|
||||||
|
- ✅ Date range selector (7/30/90/365 days)
|
||||||
|
- ✅ Summary statistics cards:
|
||||||
|
- Cards reviewed in selected period
|
||||||
|
- Overall accuracy percentage
|
||||||
|
- Total cards in learning
|
||||||
|
- Average session length (minutes)
|
||||||
|
- ✅ Daily Activity bar chart (Recharts):
|
||||||
|
- Stacked correct/incorrect reviews by date
|
||||||
|
- Interactive tooltips with detailed counts
|
||||||
|
- ✅ Accuracy Trend line chart:
|
||||||
|
- Daily accuracy percentage over time
|
||||||
|
- Smooth line visualization
|
||||||
|
- ✅ Session history table:
|
||||||
|
- Sortable by date
|
||||||
|
- Shows collection, cards reviewed, accuracy, session length
|
||||||
|
- Responsive design
|
||||||
|
- ✅ Dark mode compatible color schemes
|
||||||
|
|
||||||
|
**Progress Server Actions:**
|
||||||
|
- ✅ `getStatistics()` - Returns due cards, total learned, daily goal, streak
|
||||||
|
- ✅ `getUserProgress()` - Returns overview stats and daily activity breakdown
|
||||||
|
- ✅ `getLearningSessions()` - Returns paginated session history
|
||||||
|
- ✅ `getHanziProgress()` - Individual hanzi progress details
|
||||||
|
- ✅ `resetHanziProgress()` - Reset card to initial state
|
||||||
|
|
||||||
|
**Statistics Calculations:**
|
||||||
|
- ✅ Streak calculation algorithm (consecutive days with reviews)
|
||||||
|
- ✅ Daily activity aggregation using Map for efficient grouping
|
||||||
|
- ✅ Accuracy calculations (correct / total reviews)
|
||||||
|
- ✅ Average session length (total duration / session count)
|
||||||
|
- ✅ Date range filtering for historical data
|
||||||
|
|
||||||
|
**Recharts Integration:**
|
||||||
|
- ✅ Installed and configured Recharts library
|
||||||
|
- ✅ Line chart component for trends
|
||||||
|
- ✅ Bar chart component with stacking for activity
|
||||||
|
- ✅ Responsive containers for mobile/desktop
|
||||||
|
- ✅ Custom tooltips and legends
|
||||||
|
|
||||||
|
**Files Created:**
|
||||||
|
- `src/actions/progress.ts` - Progress tracking Server Actions (550+ lines)
|
||||||
|
- `src/app/(app)/progress/page.tsx` - Progress visualization page (380+ lines)
|
||||||
|
|
||||||
|
**Files Modified:**
|
||||||
|
- `src/app/(app)/dashboard/page.tsx` - Added real statistics and recent activity
|
||||||
|
- Navigation updated across dashboard and progress pages
|
||||||
|
|
||||||
## 🎨 Naming Conventions
|
## 🎨 Naming Conventions
|
||||||
|
|
||||||
|
|||||||
176885
data/initialization/complete.json
Normal file
176885
data/initialization/complete.json
Normal file
File diff suppressed because it is too large
Load Diff
2
package-lock.json
generated
2
package-lock.json
generated
@@ -17,7 +17,7 @@
|
|||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.4",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.4",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ const toggleUserStatusSchema = z.object({
|
|||||||
userId: z.string().min(1),
|
userId: z.string().min(1),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const initializeDatabaseSchema = z.object({
|
||||||
|
fileNames: z.array(z.string()).min(1, "At least one file is required"),
|
||||||
|
cleanData: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// GLOBAL COLLECTIONS
|
// GLOBAL COLLECTIONS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -517,3 +522,250 @@ export async function toggleUserStatus(userId: string): Promise<ActionResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DATABASE INITIALIZATION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of available initialization files
|
||||||
|
*/
|
||||||
|
export async function getInitializationFiles(): Promise<ActionResult<string[]>> {
|
||||||
|
try {
|
||||||
|
await requireAdmin()
|
||||||
|
|
||||||
|
const fs = await import("fs/promises")
|
||||||
|
const path = await import("path")
|
||||||
|
const dirPath = path.join(process.cwd(), "data", "initialization")
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = await fs.readdir(dirPath)
|
||||||
|
// Filter for JSON files only
|
||||||
|
const jsonFiles = files.filter(file => file.endsWith(".json"))
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: jsonFiles,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Initialization directory not found",
|
||||||
|
data: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.startsWith("Unauthorized")) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Failed to get initialization files",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize database with hanzi and collections from a JSON file
|
||||||
|
*
|
||||||
|
* NOTE: This is kept for backwards compatibility but the API route
|
||||||
|
* /api/admin/initialize should be used for real-time progress updates
|
||||||
|
*
|
||||||
|
* This function:
|
||||||
|
* 1. Optionally cleans all hanzi and collections
|
||||||
|
* 2. Imports hanzi from the specified file
|
||||||
|
* 3. Creates collections for each unique HSK level found
|
||||||
|
* 4. Adds hanzi to their corresponding level collections
|
||||||
|
*
|
||||||
|
* @param fileName - Name of the file in data/initialization/ (e.g., "complete.json")
|
||||||
|
* @param cleanData - If true, deletes all hanzi and collections before import
|
||||||
|
* @returns ActionResult with import statistics
|
||||||
|
*/
|
||||||
|
export async function initializeDatabase(
|
||||||
|
fileName: string,
|
||||||
|
cleanData: boolean = false
|
||||||
|
): Promise<ActionResult<{
|
||||||
|
imported: number
|
||||||
|
collectionsCreated: number
|
||||||
|
hanziAddedToCollections: number
|
||||||
|
}>> {
|
||||||
|
try {
|
||||||
|
await requireAdmin()
|
||||||
|
|
||||||
|
const validation = initializeDatabaseSchema.safeParse({ fileName, cleanData })
|
||||||
|
|
||||||
|
if (!validation.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Validation failed",
|
||||||
|
errors: validation.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read file from filesystem
|
||||||
|
const fs = await import("fs/promises")
|
||||||
|
const path = await import("path")
|
||||||
|
const filePath = path.join(process.cwd(), "data", "initialization", fileName)
|
||||||
|
|
||||||
|
let fileData: string
|
||||||
|
try {
|
||||||
|
fileData = await fs.readFile(filePath, "utf-8")
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Failed to read file: ${fileName}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the JSON file
|
||||||
|
const { result: parseResult, data: parsedData } = parseHSKJson(fileData)
|
||||||
|
|
||||||
|
if (!parseResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Parse failed: ${parseResult.errors.length} errors found`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean data if requested
|
||||||
|
if (cleanData) {
|
||||||
|
// Delete all collection items first (foreign key constraint)
|
||||||
|
await prisma.collectionItem.deleteMany({})
|
||||||
|
// Delete all collections
|
||||||
|
await prisma.collection.deleteMany({})
|
||||||
|
// Delete all user hanzi progress
|
||||||
|
await prisma.userHanziProgress.deleteMany({})
|
||||||
|
// Delete all session reviews
|
||||||
|
await prisma.sessionReview.deleteMany({})
|
||||||
|
// Delete all learning sessions
|
||||||
|
await prisma.learningSession.deleteMany({})
|
||||||
|
|
||||||
|
// Delete all hanzi-related data
|
||||||
|
await prisma.hanziMeaning.deleteMany({})
|
||||||
|
await prisma.hanziTranscription.deleteMany({})
|
||||||
|
await prisma.hanziClassifier.deleteMany({})
|
||||||
|
await prisma.hanziForm.deleteMany({})
|
||||||
|
await prisma.hanziHSKLevel.deleteMany({})
|
||||||
|
await prisma.hanziPOS.deleteMany({})
|
||||||
|
await prisma.hanzi.deleteMany({})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import hanzi
|
||||||
|
await saveParsedHanzi(parsedData, true)
|
||||||
|
|
||||||
|
// Extract all unique HSK levels from the parsed data
|
||||||
|
const uniqueLevels = new Set<string>()
|
||||||
|
parsedData.forEach(hanzi => {
|
||||||
|
hanzi.hskLevels.forEach(level => {
|
||||||
|
uniqueLevels.add(level)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create collections for each level (if they don't exist)
|
||||||
|
const levelCollections = new Map<string, string>() // level -> collectionId
|
||||||
|
let collectionsCreated = 0
|
||||||
|
|
||||||
|
for (const level of uniqueLevels) {
|
||||||
|
// Check if collection already exists
|
||||||
|
const existingCollection = await prisma.collection.findFirst({
|
||||||
|
where: {
|
||||||
|
name: `HSK ${level}`,
|
||||||
|
isPublic: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingCollection) {
|
||||||
|
levelCollections.set(level, existingCollection.id)
|
||||||
|
} else {
|
||||||
|
// Create new collection
|
||||||
|
const session = await auth()
|
||||||
|
const newCollection = await prisma.collection.create({
|
||||||
|
data: {
|
||||||
|
name: `HSK ${level}`,
|
||||||
|
description: `HSK ${level} vocabulary collection`,
|
||||||
|
isPublic: true,
|
||||||
|
createdBy: session?.user?.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
levelCollections.set(level, newCollection.id)
|
||||||
|
collectionsCreated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add hanzi to their corresponding collections
|
||||||
|
let hanziAddedToCollections = 0
|
||||||
|
|
||||||
|
for (const hanzi of parsedData) {
|
||||||
|
// Find the hanzi record in the database
|
||||||
|
const hanziRecord = await prisma.hanzi.findUnique({
|
||||||
|
where: { simplified: hanzi.simplified },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!hanziRecord) continue
|
||||||
|
|
||||||
|
// Add to each level collection
|
||||||
|
for (const level of hanzi.hskLevels) {
|
||||||
|
const collectionId = levelCollections.get(level)
|
||||||
|
if (!collectionId) continue
|
||||||
|
|
||||||
|
// Check if already in collection
|
||||||
|
const existingItem = await prisma.collectionItem.findUnique({
|
||||||
|
where: {
|
||||||
|
collectionId_hanziId: {
|
||||||
|
collectionId,
|
||||||
|
hanziId: hanziRecord.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existingItem) {
|
||||||
|
// Get the next orderIndex
|
||||||
|
const maxOrderIndex = await prisma.collectionItem.findFirst({
|
||||||
|
where: { collectionId },
|
||||||
|
orderBy: { orderIndex: "desc" },
|
||||||
|
select: { orderIndex: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.collectionItem.create({
|
||||||
|
data: {
|
||||||
|
collectionId,
|
||||||
|
hanziId: hanziRecord.id,
|
||||||
|
orderIndex: (maxOrderIndex?.orderIndex ?? -1) + 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
hanziAddedToCollections++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/collections")
|
||||||
|
revalidatePath("/hanzi")
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
imported: parseResult.imported,
|
||||||
|
collectionsCreated,
|
||||||
|
hanziAddedToCollections,
|
||||||
|
},
|
||||||
|
message: `Successfully initialized: ${parseResult.imported} hanzi imported, ${collectionsCreated} collections created, ${hanziAddedToCollections} hanzi added to collections`,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.startsWith("Unauthorized")) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("Database initialization error:", error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Failed to initialize database",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
911
src/actions/learning.ts
Normal file
911
src/actions/learning.ts
Normal file
@@ -0,0 +1,911 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { auth } from "@/lib/auth"
|
||||||
|
import { Difficulty } from "@prisma/client"
|
||||||
|
import {
|
||||||
|
selectCardsForSession,
|
||||||
|
calculateCorrectAnswer,
|
||||||
|
calculateIncorrectAnswer,
|
||||||
|
generateWrongAnswers,
|
||||||
|
shuffleOptions,
|
||||||
|
INITIAL_PROGRESS,
|
||||||
|
type SelectableCard,
|
||||||
|
type HanziOption,
|
||||||
|
} from "@/lib/learning/sm2"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard action result type
|
||||||
|
*/
|
||||||
|
export type ActionResult<T = void> = {
|
||||||
|
success: boolean
|
||||||
|
data?: T
|
||||||
|
message?: string
|
||||||
|
errors?: Record<string, string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VALIDATION SCHEMAS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const startLearningSessionSchema = z.object({
|
||||||
|
collectionId: z.string().optional(),
|
||||||
|
cardsCount: z.number().int().positive().default(20),
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitAnswerSchema = z.object({
|
||||||
|
sessionId: z.string().min(1),
|
||||||
|
hanziId: z.string().min(1),
|
||||||
|
selectedPinyin: z.string().min(1),
|
||||||
|
correct: z.boolean(),
|
||||||
|
timeSpentMs: z.number().int().min(0),
|
||||||
|
})
|
||||||
|
|
||||||
|
const endSessionSchema = z.object({
|
||||||
|
sessionId: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateCardDifficultySchema = z.object({
|
||||||
|
hanziId: z.string().min(1),
|
||||||
|
difficulty: z.nativeEnum(Difficulty),
|
||||||
|
})
|
||||||
|
|
||||||
|
const removeFromLearningSchema = z.object({
|
||||||
|
hanziId: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LEARNING ACTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a learning session
|
||||||
|
*
|
||||||
|
* Selects due cards using SM-2 algorithm and creates a session.
|
||||||
|
* If no collectionId provided, selects from all user's collections.
|
||||||
|
*
|
||||||
|
* @param collectionId - Optional collection to learn from
|
||||||
|
* @param cardsCount - Number of cards to include (default: user preference)
|
||||||
|
* @returns Session with cards and answer options
|
||||||
|
*/
|
||||||
|
export async function startLearningSession(
|
||||||
|
collectionId?: string,
|
||||||
|
cardsCount?: number
|
||||||
|
): Promise<ActionResult<{
|
||||||
|
sessionId: string
|
||||||
|
cards: Array<{
|
||||||
|
hanziId: string
|
||||||
|
simplified: string
|
||||||
|
options: string[]
|
||||||
|
correctPinyin: string
|
||||||
|
meaning: string
|
||||||
|
}>
|
||||||
|
}>> {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Authentication required",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user preferences
|
||||||
|
const preferences = await prisma.userPreference.findUnique({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
const cardsPerSession = cardsCount || preferences?.cardsPerSession || 20
|
||||||
|
|
||||||
|
const validation = startLearningSessionSchema.safeParse({
|
||||||
|
collectionId,
|
||||||
|
cardsCount: cardsPerSession,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!validation.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Validation failed",
|
||||||
|
errors: validation.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's hanzi progress for due cards
|
||||||
|
const whereClause: any = {
|
||||||
|
userId: session.user.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
// If collectionId provided, filter by collection
|
||||||
|
if (collectionId) {
|
||||||
|
whereClause.hanzi = {
|
||||||
|
collectionItems: {
|
||||||
|
some: {
|
||||||
|
collectionId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userProgress = await prisma.userHanziProgress.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
include: {
|
||||||
|
hanzi: {
|
||||||
|
include: {
|
||||||
|
forms: {
|
||||||
|
where: { isDefault: true },
|
||||||
|
include: {
|
||||||
|
transcriptions: {
|
||||||
|
where: { type: "pinyin" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hskLevels: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Convert to SelectableCard format for SM-2 algorithm
|
||||||
|
const selectableCards: SelectableCard[] = userProgress.map(progress => ({
|
||||||
|
id: progress.hanziId,
|
||||||
|
nextReviewDate: progress.nextReviewDate,
|
||||||
|
incorrectCount: progress.incorrectCount,
|
||||||
|
consecutiveCorrect: progress.consecutiveCorrect,
|
||||||
|
manualDifficulty: progress.manualDifficulty as Difficulty,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Select cards using SM-2 algorithm
|
||||||
|
const selectedCards = selectCardsForSession(selectableCards, cardsPerSession)
|
||||||
|
|
||||||
|
// If not enough due cards, add new cards from collection
|
||||||
|
if (selectedCards.length < cardsPerSession) {
|
||||||
|
const neededCards = cardsPerSession - selectedCards.length
|
||||||
|
|
||||||
|
// Find hanzi not yet in user progress
|
||||||
|
const newHanziWhereClause: any = {
|
||||||
|
id: {
|
||||||
|
notIn: userProgress.map(p => p.hanziId),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collectionId) {
|
||||||
|
newHanziWhereClause.collectionItems = {
|
||||||
|
some: {
|
||||||
|
collectionId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, get all available new hanzi IDs (lightweight query)
|
||||||
|
const availableNewHanzi = await prisma.hanzi.findMany({
|
||||||
|
where: newHanziWhereClause,
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Randomly select N hanzi IDs from all available
|
||||||
|
// Fisher-Yates shuffle
|
||||||
|
const shuffledIds = [...availableNewHanzi]
|
||||||
|
for (let i = shuffledIds.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1))
|
||||||
|
;[shuffledIds[i], shuffledIds[j]] = [shuffledIds[j], shuffledIds[i]]
|
||||||
|
}
|
||||||
|
const selectedNewHanziIds = shuffledIds.slice(0, neededCards).map(h => h.id)
|
||||||
|
|
||||||
|
// Now fetch full details for the randomly selected hanzi
|
||||||
|
const newHanzi = await prisma.hanzi.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: selectedNewHanziIds },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
forms: {
|
||||||
|
where: { isDefault: true },
|
||||||
|
include: {
|
||||||
|
transcriptions: {
|
||||||
|
where: { type: "pinyin" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hskLevels: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create initial progress for new cards
|
||||||
|
const now = new Date()
|
||||||
|
for (const hanzi of newHanzi) {
|
||||||
|
await prisma.userHanziProgress.create({
|
||||||
|
data: {
|
||||||
|
userId: session.user.id,
|
||||||
|
hanziId: hanzi.id,
|
||||||
|
...INITIAL_PROGRESS,
|
||||||
|
nextReviewDate: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
selectedCards.push({
|
||||||
|
id: hanzi.id,
|
||||||
|
nextReviewDate: now,
|
||||||
|
incorrectCount: 0,
|
||||||
|
consecutiveCorrect: 0,
|
||||||
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedCards.length === 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "No cards available to learn",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shuffle final card set (in case new cards were added after initial shuffle)
|
||||||
|
// Fisher-Yates shuffle
|
||||||
|
for (let i = selectedCards.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1))
|
||||||
|
;[selectedCards[i], selectedCards[j]] = [selectedCards[j], selectedCards[i]]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create learning session
|
||||||
|
const learningSession = await prisma.learningSession.create({
|
||||||
|
data: {
|
||||||
|
userId: session.user.id,
|
||||||
|
collectionId: collectionId || null,
|
||||||
|
cardsReviewed: selectedCards.length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get full hanzi details for selected cards
|
||||||
|
const selectedHanziIds = selectedCards.map(c => c.id)
|
||||||
|
const hanziDetails = await prisma.hanzi.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: selectedHanziIds },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
forms: {
|
||||||
|
where: { isDefault: true },
|
||||||
|
include: {
|
||||||
|
transcriptions: {
|
||||||
|
where: { type: "pinyin" },
|
||||||
|
},
|
||||||
|
meanings: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hskLevels: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Generate answer options for each card
|
||||||
|
const cards = []
|
||||||
|
for (const hanzi of hanziDetails) {
|
||||||
|
const defaultForm = hanzi.forms[0]
|
||||||
|
if (!defaultForm) continue
|
||||||
|
|
||||||
|
const pinyinTranscription = defaultForm.transcriptions[0]
|
||||||
|
if (!pinyinTranscription) continue
|
||||||
|
|
||||||
|
const correctPinyin = pinyinTranscription.value
|
||||||
|
const characterCount = hanzi.simplified.length
|
||||||
|
|
||||||
|
// Get HSK level for this hanzi
|
||||||
|
const hskLevel = hanzi.hskLevels[0]?.level || "new-1"
|
||||||
|
|
||||||
|
// Get ALL available hanzi IDs from same HSK level (lightweight query)
|
||||||
|
// This prevents always fetching the same alphabetically-first hanzi
|
||||||
|
const allSameHskIds = await prisma.hanzi.findMany({
|
||||||
|
where: {
|
||||||
|
id: { not: hanzi.id },
|
||||||
|
hskLevels: {
|
||||||
|
some: {
|
||||||
|
level: hskLevel,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
simplified: true, // Need this for character count filtering
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Filter to same character count
|
||||||
|
const sameCharCountIds = allSameHskIds.filter(
|
||||||
|
h => h.simplified.length === characterCount
|
||||||
|
)
|
||||||
|
|
||||||
|
// Shuffle ALL matching IDs
|
||||||
|
const shuffledIds = [...sameCharCountIds]
|
||||||
|
for (let i = shuffledIds.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1))
|
||||||
|
;[shuffledIds[i], shuffledIds[j]] = [shuffledIds[j], shuffledIds[i]]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take first 50 from shuffled (or all if less than 50)
|
||||||
|
const selectedIds = shuffledIds.slice(0, 50).map(h => h.id)
|
||||||
|
|
||||||
|
// Fetch full details for selected IDs
|
||||||
|
let candidatesForWrongAnswers = await prisma.hanzi.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: selectedIds },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
forms: {
|
||||||
|
where: { isDefault: true },
|
||||||
|
include: {
|
||||||
|
transcriptions: {
|
||||||
|
where: { type: "pinyin" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// If not enough candidates, get more from any HSK level with same character count
|
||||||
|
if (candidatesForWrongAnswers.length < 10) {
|
||||||
|
const additionalAllIds = await prisma.hanzi.findMany({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
not: hanzi.id,
|
||||||
|
notIn: candidatesForWrongAnswers.map(h => h.id),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
simplified: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const additionalSameCharIds = additionalAllIds.filter(
|
||||||
|
h => h.simplified.length === characterCount
|
||||||
|
)
|
||||||
|
|
||||||
|
// Shuffle additional IDs
|
||||||
|
const shuffledAdditional = [...additionalSameCharIds]
|
||||||
|
for (let i = shuffledAdditional.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1))
|
||||||
|
;[shuffledAdditional[i], shuffledAdditional[j]] = [shuffledAdditional[j], shuffledAdditional[i]]
|
||||||
|
}
|
||||||
|
|
||||||
|
const additionalSelectedIds = shuffledAdditional.slice(0, 30).map(h => h.id)
|
||||||
|
|
||||||
|
const additionalHanzi = await prisma.hanzi.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: additionalSelectedIds },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
forms: {
|
||||||
|
where: { isDefault: true },
|
||||||
|
include: {
|
||||||
|
transcriptions: {
|
||||||
|
where: { type: "pinyin" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
candidatesForWrongAnswers = [...candidatesForWrongAnswers, ...additionalHanzi]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to HanziOption format
|
||||||
|
const hanziOptions: HanziOption[] = candidatesForWrongAnswers
|
||||||
|
.map(h => {
|
||||||
|
const form = h.forms[0]
|
||||||
|
const pinyin = form?.transcriptions[0]
|
||||||
|
if (!form || !pinyin) return null
|
||||||
|
return {
|
||||||
|
id: h.id,
|
||||||
|
simplified: h.simplified,
|
||||||
|
pinyin: pinyin.value,
|
||||||
|
hskLevel: hskLevel,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((h): h is HanziOption => h !== null)
|
||||||
|
|
||||||
|
// Generate wrong answers
|
||||||
|
let wrongAnswers: string[] = []
|
||||||
|
try {
|
||||||
|
wrongAnswers = generateWrongAnswers(
|
||||||
|
{
|
||||||
|
id: hanzi.id,
|
||||||
|
simplified: hanzi.simplified,
|
||||||
|
pinyin: correctPinyin,
|
||||||
|
hskLevel,
|
||||||
|
},
|
||||||
|
hanziOptions
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
// If not enough options, use random ones
|
||||||
|
wrongAnswers = hanziOptions
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(o => o.pinyin)
|
||||||
|
.filter(p => p !== correctPinyin)
|
||||||
|
|
||||||
|
// Fill with placeholders if still not enough
|
||||||
|
while (wrongAnswers.length < 3) {
|
||||||
|
wrongAnswers.push(`Option ${wrongAnswers.length + 1}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shuffle all options
|
||||||
|
const allOptions = shuffleOptions([correctPinyin, ...wrongAnswers])
|
||||||
|
|
||||||
|
// Get English meaning (first meaning)
|
||||||
|
const meaning = defaultForm.meanings[0]?.meaning || ""
|
||||||
|
|
||||||
|
cards.push({
|
||||||
|
hanziId: hanzi.id,
|
||||||
|
simplified: hanzi.simplified,
|
||||||
|
options: allOptions,
|
||||||
|
correctPinyin,
|
||||||
|
meaning,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
sessionId: learningSession.id,
|
||||||
|
cards,
|
||||||
|
},
|
||||||
|
message: `Learning session started with ${cards.length} cards`,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Start learning session error:", error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Failed to start learning session",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit an answer for a card
|
||||||
|
*
|
||||||
|
* Records the answer, updates SM-2 progress, and creates a session review.
|
||||||
|
*
|
||||||
|
* @param sessionId - Current session ID
|
||||||
|
* @param hanziId - Hanzi being reviewed
|
||||||
|
* @param selectedPinyin - User's selected answer
|
||||||
|
* @param correct - Whether answer was correct
|
||||||
|
* @param timeSpentMs - Time spent on this card in milliseconds
|
||||||
|
* @returns Updated progress information
|
||||||
|
*/
|
||||||
|
export async function submitAnswer(
|
||||||
|
sessionId: string,
|
||||||
|
hanziId: string,
|
||||||
|
selectedPinyin: string,
|
||||||
|
correct: boolean,
|
||||||
|
timeSpentMs: number
|
||||||
|
): Promise<ActionResult<{
|
||||||
|
easeFactor: number
|
||||||
|
interval: number
|
||||||
|
consecutiveCorrect: number
|
||||||
|
nextReviewDate: Date
|
||||||
|
}>> {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Authentication required",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = submitAnswerSchema.safeParse({
|
||||||
|
sessionId,
|
||||||
|
hanziId,
|
||||||
|
selectedPinyin,
|
||||||
|
correct,
|
||||||
|
timeSpentMs,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!validation.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Validation failed",
|
||||||
|
errors: validation.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify session belongs to user
|
||||||
|
const learningSession = await prisma.learningSession.findUnique({
|
||||||
|
where: { id: sessionId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!learningSession || learningSession.userId !== session.user.id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid session",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current progress
|
||||||
|
const progress = await prisma.userHanziProgress.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_hanziId: {
|
||||||
|
userId: session.user.id,
|
||||||
|
hanziId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!progress) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Progress not found",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate new progress using SM-2 algorithm
|
||||||
|
const reviewDate = new Date()
|
||||||
|
const updatedProgress = correct
|
||||||
|
? calculateCorrectAnswer(
|
||||||
|
{
|
||||||
|
easeFactor: progress.easeFactor,
|
||||||
|
interval: progress.interval,
|
||||||
|
consecutiveCorrect: progress.consecutiveCorrect,
|
||||||
|
incorrectCount: progress.incorrectCount,
|
||||||
|
nextReviewDate: progress.nextReviewDate,
|
||||||
|
},
|
||||||
|
reviewDate
|
||||||
|
)
|
||||||
|
: calculateIncorrectAnswer(
|
||||||
|
{
|
||||||
|
easeFactor: progress.easeFactor,
|
||||||
|
interval: progress.interval,
|
||||||
|
consecutiveCorrect: progress.consecutiveCorrect,
|
||||||
|
incorrectCount: progress.incorrectCount,
|
||||||
|
nextReviewDate: progress.nextReviewDate,
|
||||||
|
},
|
||||||
|
reviewDate
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update progress
|
||||||
|
await prisma.userHanziProgress.update({
|
||||||
|
where: {
|
||||||
|
userId_hanziId: {
|
||||||
|
userId: session.user.id,
|
||||||
|
hanziId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
easeFactor: updatedProgress.easeFactor,
|
||||||
|
interval: updatedProgress.interval,
|
||||||
|
consecutiveCorrect: updatedProgress.consecutiveCorrect,
|
||||||
|
incorrectCount: updatedProgress.incorrectCount,
|
||||||
|
nextReviewDate: updatedProgress.nextReviewDate,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create session review record
|
||||||
|
await prisma.sessionReview.create({
|
||||||
|
data: {
|
||||||
|
sessionId,
|
||||||
|
hanziId,
|
||||||
|
isCorrect: correct,
|
||||||
|
responseTime: timeSpentMs,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update session counts
|
||||||
|
await prisma.learningSession.update({
|
||||||
|
where: { id: sessionId },
|
||||||
|
data: {
|
||||||
|
correctAnswers: correct
|
||||||
|
? { increment: 1 }
|
||||||
|
: undefined,
|
||||||
|
incorrectAnswers: !correct
|
||||||
|
? { increment: 1 }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
easeFactor: updatedProgress.easeFactor,
|
||||||
|
interval: updatedProgress.interval,
|
||||||
|
consecutiveCorrect: updatedProgress.consecutiveCorrect,
|
||||||
|
nextReviewDate: updatedProgress.nextReviewDate,
|
||||||
|
},
|
||||||
|
message: correct ? "Correct answer!" : "Incorrect answer",
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Submit answer error:", error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Failed to submit answer",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End a learning session
|
||||||
|
*
|
||||||
|
* Marks the session as complete and returns summary statistics.
|
||||||
|
*
|
||||||
|
* @param sessionId - Session to end
|
||||||
|
* @returns Session summary with stats
|
||||||
|
*/
|
||||||
|
export async function endSession(
|
||||||
|
sessionId: string
|
||||||
|
): Promise<ActionResult<{
|
||||||
|
totalCards: number
|
||||||
|
correctCount: number
|
||||||
|
incorrectCount: number
|
||||||
|
accuracyPercent: number
|
||||||
|
durationMinutes: number
|
||||||
|
}>> {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Authentication required",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = endSessionSchema.safeParse({ sessionId })
|
||||||
|
|
||||||
|
if (!validation.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Validation failed",
|
||||||
|
errors: validation.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify session belongs to user
|
||||||
|
const learningSession = await prisma.learningSession.findUnique({
|
||||||
|
where: { id: sessionId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!learningSession || learningSession.userId !== session.user.id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid session",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (learningSession.endedAt) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Session already completed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark session as complete
|
||||||
|
const endedAt = new Date()
|
||||||
|
await prisma.learningSession.update({
|
||||||
|
where: { id: sessionId },
|
||||||
|
data: { endedAt },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate summary stats
|
||||||
|
const totalCards = learningSession.cardsReviewed
|
||||||
|
const correctCount = learningSession.correctAnswers
|
||||||
|
const incorrectCount = learningSession.incorrectAnswers
|
||||||
|
const accuracyPercent =
|
||||||
|
totalCards > 0 ? Math.round((correctCount / totalCards) * 100) : 0
|
||||||
|
const durationMs = endedAt.getTime() - learningSession.startedAt.getTime()
|
||||||
|
const durationMinutes = Math.round(durationMs / 1000 / 60)
|
||||||
|
|
||||||
|
revalidatePath("/dashboard")
|
||||||
|
revalidatePath("/progress")
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
totalCards,
|
||||||
|
correctCount,
|
||||||
|
incorrectCount,
|
||||||
|
accuracyPercent,
|
||||||
|
durationMinutes,
|
||||||
|
},
|
||||||
|
message: "Session completed successfully",
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("End session error:", error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Failed to end session",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get count of due cards
|
||||||
|
*
|
||||||
|
* Returns counts of cards due now, today, and this week.
|
||||||
|
*
|
||||||
|
* @returns Due card counts
|
||||||
|
*/
|
||||||
|
export async function getDueCards(): Promise<ActionResult<{
|
||||||
|
dueNow: number
|
||||||
|
dueToday: number
|
||||||
|
dueThisWeek: number
|
||||||
|
}>> {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Authentication required",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const endOfToday = new Date(now)
|
||||||
|
endOfToday.setHours(23, 59, 59, 999)
|
||||||
|
const endOfWeek = new Date(now)
|
||||||
|
endOfWeek.setDate(endOfWeek.getDate() + 7)
|
||||||
|
|
||||||
|
const [dueNow, dueToday, dueThisWeek] = await Promise.all([
|
||||||
|
prisma.userHanziProgress.count({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id,
|
||||||
|
nextReviewDate: {
|
||||||
|
lte: now,
|
||||||
|
},
|
||||||
|
manualDifficulty: {
|
||||||
|
not: Difficulty.SUSPENDED,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.userHanziProgress.count({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id,
|
||||||
|
nextReviewDate: {
|
||||||
|
lte: endOfToday,
|
||||||
|
},
|
||||||
|
manualDifficulty: {
|
||||||
|
not: Difficulty.SUSPENDED,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.userHanziProgress.count({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id,
|
||||||
|
nextReviewDate: {
|
||||||
|
lte: endOfWeek,
|
||||||
|
},
|
||||||
|
manualDifficulty: {
|
||||||
|
not: Difficulty.SUSPENDED,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
dueNow,
|
||||||
|
dueToday,
|
||||||
|
dueThisWeek,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Get due cards error:", error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Failed to get due cards",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update manual difficulty for a card
|
||||||
|
*
|
||||||
|
* Allows user to manually mark cards as EASY, NORMAL, HARD, or SUSPENDED.
|
||||||
|
*
|
||||||
|
* @param hanziId - Hanzi to update
|
||||||
|
* @param difficulty - New difficulty level
|
||||||
|
* @returns Success status
|
||||||
|
*/
|
||||||
|
export async function updateCardDifficulty(
|
||||||
|
hanziId: string,
|
||||||
|
difficulty: Difficulty
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Authentication required",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = updateCardDifficultySchema.safeParse({ hanziId, difficulty })
|
||||||
|
|
||||||
|
if (!validation.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Validation failed",
|
||||||
|
errors: validation.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.userHanziProgress.update({
|
||||||
|
where: {
|
||||||
|
userId_hanziId: {
|
||||||
|
userId: session.user.id,
|
||||||
|
hanziId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
manualDifficulty: difficulty,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath("/learn")
|
||||||
|
revalidatePath("/dashboard")
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Card difficulty updated to ${difficulty}`,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Update card difficulty error:", error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Failed to update card difficulty",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a card from learning
|
||||||
|
*
|
||||||
|
* Suspends the card so it won't appear in future sessions.
|
||||||
|
*
|
||||||
|
* @param hanziId - Hanzi to remove
|
||||||
|
* @returns Success status
|
||||||
|
*/
|
||||||
|
export async function removeFromLearning(hanziId: string): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Authentication required",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = removeFromLearningSchema.safeParse({ hanziId })
|
||||||
|
|
||||||
|
if (!validation.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Validation failed",
|
||||||
|
errors: validation.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.userHanziProgress.update({
|
||||||
|
where: {
|
||||||
|
userId_hanziId: {
|
||||||
|
userId: session.user.id,
|
||||||
|
hanziId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
manualDifficulty: Difficulty.SUSPENDED,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath("/learn")
|
||||||
|
revalidatePath("/dashboard")
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Card removed from learning",
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Remove from learning error:", error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Failed to remove card from learning",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
558
src/actions/progress.ts
Normal file
558
src/actions/progress.ts
Normal file
@@ -0,0 +1,558 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { auth } from "@/lib/auth"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard action result type
|
||||||
|
*/
|
||||||
|
export type ActionResult<T = void> = {
|
||||||
|
success: boolean
|
||||||
|
data?: T
|
||||||
|
message?: string
|
||||||
|
errors?: Record<string, string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VALIDATION SCHEMAS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const dateRangeSchema = z.object({
|
||||||
|
startDate: z.date().optional(),
|
||||||
|
endDate: z.date().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const hanziIdSchema = z.object({
|
||||||
|
hanziId: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PROGRESS ACTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get dashboard statistics
|
||||||
|
* Returns counts for due cards, learned cards, and daily progress
|
||||||
|
*/
|
||||||
|
export async function getStatistics(): Promise<ActionResult<{
|
||||||
|
dueNow: number
|
||||||
|
dueToday: number
|
||||||
|
dueThisWeek: number
|
||||||
|
totalLearned: number
|
||||||
|
reviewedToday: number
|
||||||
|
dailyGoal: number
|
||||||
|
streak: number
|
||||||
|
}>> {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Authentication required",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
|
const todayEnd = new Date(todayStart)
|
||||||
|
todayEnd.setDate(todayEnd.getDate() + 1)
|
||||||
|
|
||||||
|
const weekEnd = new Date(todayStart)
|
||||||
|
weekEnd.setDate(weekEnd.getDate() + 7)
|
||||||
|
|
||||||
|
// Get user preferences for daily goal
|
||||||
|
const preferences = await prisma.userPreference.findUnique({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
const dailyGoal = preferences?.dailyGoal || 50
|
||||||
|
|
||||||
|
// Count cards due now
|
||||||
|
const dueNow = await prisma.userHanziProgress.count({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id,
|
||||||
|
nextReviewDate: {
|
||||||
|
lte: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Count cards due today
|
||||||
|
const dueToday = await prisma.userHanziProgress.count({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id,
|
||||||
|
nextReviewDate: {
|
||||||
|
lte: todayEnd,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Count cards due this week
|
||||||
|
const dueThisWeek = await prisma.userHanziProgress.count({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id,
|
||||||
|
nextReviewDate: {
|
||||||
|
lte: weekEnd,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Count total cards with progress
|
||||||
|
const totalLearned = await prisma.userHanziProgress.count({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Count cards reviewed today (from session reviews)
|
||||||
|
const reviewedToday = await prisma.sessionReview.count({
|
||||||
|
where: {
|
||||||
|
session: {
|
||||||
|
userId: session.user.id,
|
||||||
|
startedAt: {
|
||||||
|
gte: todayStart,
|
||||||
|
lt: todayEnd,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate streak (consecutive days with reviews)
|
||||||
|
const streak = await calculateStreak(session.user.id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
dueNow,
|
||||||
|
dueToday,
|
||||||
|
dueThisWeek,
|
||||||
|
totalLearned,
|
||||||
|
reviewedToday,
|
||||||
|
dailyGoal,
|
||||||
|
streak,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Get statistics error:", error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Failed to get statistics",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate learning streak (consecutive days with reviews)
|
||||||
|
*/
|
||||||
|
async function calculateStreak(userId: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
const sessions = await prisma.learningSession.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
cardsReviewed: {
|
||||||
|
gt: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
startedAt: "desc",
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
startedAt: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (sessions.length === 0) return 0
|
||||||
|
|
||||||
|
let streak = 0
|
||||||
|
let currentDate = new Date()
|
||||||
|
currentDate.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
for (const session of sessions) {
|
||||||
|
const sessionDate = new Date(session.startedAt)
|
||||||
|
sessionDate.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const daysDiff = Math.floor(
|
||||||
|
(currentDate.getTime() - sessionDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (daysDiff === streak) {
|
||||||
|
streak++
|
||||||
|
} else if (daysDiff > streak) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return streak
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Calculate streak error:", error)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user progress overview with optional date range
|
||||||
|
*/
|
||||||
|
export async function getUserProgress(
|
||||||
|
startDate?: Date,
|
||||||
|
endDate?: Date
|
||||||
|
): Promise<ActionResult<{
|
||||||
|
totalCards: number
|
||||||
|
cardsReviewed: number
|
||||||
|
correctAnswers: number
|
||||||
|
incorrectAnswers: number
|
||||||
|
accuracyPercent: number
|
||||||
|
averageSessionLength: number
|
||||||
|
dailyActivity: Array<{
|
||||||
|
date: string
|
||||||
|
reviews: number
|
||||||
|
correct: number
|
||||||
|
incorrect: number
|
||||||
|
}>
|
||||||
|
}>> {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Authentication required",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = dateRangeSchema.safeParse({ startDate, endDate })
|
||||||
|
if (!validation.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid date range",
|
||||||
|
errors: validation.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to last 30 days if no range provided
|
||||||
|
const end = endDate || new Date()
|
||||||
|
const start = startDate || new Date(end.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||||
|
|
||||||
|
// Get all sessions in date range
|
||||||
|
const sessions = await prisma.learningSession.findMany({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id,
|
||||||
|
startedAt: {
|
||||||
|
gte: start,
|
||||||
|
lte: end,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
reviews: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalCards = await prisma.userHanziProgress.count({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
const cardsReviewed = sessions.reduce((sum, s) => sum + s.cardsReviewed, 0)
|
||||||
|
const correctAnswers = sessions.reduce((sum, s) => sum + s.correctAnswers, 0)
|
||||||
|
const incorrectAnswers = sessions.reduce((sum, s) => sum + s.incorrectAnswers, 0)
|
||||||
|
|
||||||
|
const accuracyPercent =
|
||||||
|
cardsReviewed > 0 ? Math.round((correctAnswers / cardsReviewed) * 100) : 0
|
||||||
|
|
||||||
|
const totalDuration = sessions.reduce((sum, s) => {
|
||||||
|
if (!s.endedAt) return sum
|
||||||
|
return sum + (s.endedAt.getTime() - s.startedAt.getTime())
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
const averageSessionLength =
|
||||||
|
sessions.length > 0
|
||||||
|
? Math.round(totalDuration / sessions.length / 1000 / 60) // minutes
|
||||||
|
: 0
|
||||||
|
|
||||||
|
// Daily activity breakdown
|
||||||
|
const dailyMap = new Map<string, { reviews: number; correct: number; incorrect: number }>()
|
||||||
|
|
||||||
|
for (const session of sessions) {
|
||||||
|
const dateKey = session.startedAt.toISOString().split("T")[0]
|
||||||
|
const existing = dailyMap.get(dateKey) || { reviews: 0, correct: 0, incorrect: 0 }
|
||||||
|
|
||||||
|
dailyMap.set(dateKey, {
|
||||||
|
reviews: existing.reviews + session.cardsReviewed,
|
||||||
|
correct: existing.correct + session.correctAnswers,
|
||||||
|
incorrect: existing.incorrect + session.incorrectAnswers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyActivity = Array.from(dailyMap.entries())
|
||||||
|
.map(([date, stats]) => ({ date, ...stats }))
|
||||||
|
.sort((a, b) => a.date.localeCompare(b.date))
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
totalCards,
|
||||||
|
cardsReviewed,
|
||||||
|
correctAnswers,
|
||||||
|
incorrectAnswers,
|
||||||
|
accuracyPercent,
|
||||||
|
averageSessionLength,
|
||||||
|
dailyActivity,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Get user progress error:", error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Failed to get user progress",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent learning sessions
|
||||||
|
*/
|
||||||
|
export async function getLearningSessions(
|
||||||
|
limit: number = 10
|
||||||
|
): Promise<ActionResult<Array<{
|
||||||
|
id: string
|
||||||
|
startedAt: Date
|
||||||
|
endedAt: Date | null
|
||||||
|
cardsReviewed: number
|
||||||
|
correctAnswers: number
|
||||||
|
incorrectAnswers: number
|
||||||
|
accuracyPercent: number
|
||||||
|
collectionName: string | null
|
||||||
|
}>>> {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Authentication required",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = await prisma.learningSession.findMany({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
collection: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
startedAt: "desc",
|
||||||
|
},
|
||||||
|
take: limit,
|
||||||
|
})
|
||||||
|
|
||||||
|
const formattedSessions = sessions.map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
startedAt: s.startedAt,
|
||||||
|
endedAt: s.endedAt,
|
||||||
|
cardsReviewed: s.cardsReviewed,
|
||||||
|
correctAnswers: s.correctAnswers,
|
||||||
|
incorrectAnswers: s.incorrectAnswers,
|
||||||
|
accuracyPercent:
|
||||||
|
s.cardsReviewed > 0 ? Math.round((s.correctAnswers / s.cardsReviewed) * 100) : 0,
|
||||||
|
collectionName: s.collection?.name || "All Cards",
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: formattedSessions,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Get learning sessions error:", error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Failed to get learning sessions",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get progress for individual hanzi
|
||||||
|
*/
|
||||||
|
export async function getHanziProgress(
|
||||||
|
hanziId: string
|
||||||
|
): Promise<ActionResult<{
|
||||||
|
hanzi: {
|
||||||
|
id: string
|
||||||
|
simplified: string
|
||||||
|
pinyin: string
|
||||||
|
meaning: string
|
||||||
|
}
|
||||||
|
progress: {
|
||||||
|
correctCount: number
|
||||||
|
incorrectCount: number
|
||||||
|
consecutiveCorrect: number
|
||||||
|
easeFactor: number
|
||||||
|
interval: number
|
||||||
|
nextReviewDate: Date
|
||||||
|
manualDifficulty: string | null
|
||||||
|
}
|
||||||
|
recentReviews: Array<{
|
||||||
|
date: Date
|
||||||
|
isCorrect: boolean
|
||||||
|
responseTime: number | null
|
||||||
|
}>
|
||||||
|
}>> {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Authentication required",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = hanziIdSchema.safeParse({ hanziId })
|
||||||
|
if (!validation.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid hanzi ID",
|
||||||
|
errors: validation.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get hanzi with progress
|
||||||
|
const progress = await prisma.userHanziProgress.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_hanziId: {
|
||||||
|
userId: session.user.id,
|
||||||
|
hanziId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
hanzi: {
|
||||||
|
include: {
|
||||||
|
forms: {
|
||||||
|
where: { isDefault: true },
|
||||||
|
include: {
|
||||||
|
transcriptions: {
|
||||||
|
where: { type: "pinyin" },
|
||||||
|
},
|
||||||
|
meanings: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!progress) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "No progress found for this hanzi",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultForm = progress.hanzi.forms[0]
|
||||||
|
const pinyin = defaultForm?.transcriptions[0]?.value || ""
|
||||||
|
const meaning = defaultForm?.meanings[0]?.meaning || ""
|
||||||
|
|
||||||
|
// Get recent reviews for this hanzi
|
||||||
|
const recentReviews = await prisma.sessionReview.findMany({
|
||||||
|
where: {
|
||||||
|
hanziId,
|
||||||
|
session: {
|
||||||
|
userId: session.user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
take: 20,
|
||||||
|
select: {
|
||||||
|
createdAt: true,
|
||||||
|
isCorrect: true,
|
||||||
|
responseTime: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
hanzi: {
|
||||||
|
id: progress.hanzi.id,
|
||||||
|
simplified: progress.hanzi.simplified,
|
||||||
|
pinyin,
|
||||||
|
meaning,
|
||||||
|
},
|
||||||
|
progress: {
|
||||||
|
correctCount: progress.correctCount,
|
||||||
|
incorrectCount: progress.incorrectCount,
|
||||||
|
consecutiveCorrect: progress.consecutiveCorrect,
|
||||||
|
easeFactor: progress.easeFactor,
|
||||||
|
interval: progress.interval,
|
||||||
|
nextReviewDate: progress.nextReviewDate,
|
||||||
|
manualDifficulty: progress.manualDifficulty,
|
||||||
|
},
|
||||||
|
recentReviews: recentReviews.map(r => ({
|
||||||
|
date: r.createdAt,
|
||||||
|
isCorrect: r.isCorrect,
|
||||||
|
responseTime: r.responseTime,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Get hanzi progress error:", error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Failed to get hanzi progress",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset progress for a specific hanzi
|
||||||
|
*/
|
||||||
|
export async function resetHanziProgress(
|
||||||
|
hanziId: string
|
||||||
|
): Promise<ActionResult<void>> {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Authentication required",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = hanziIdSchema.safeParse({ hanziId })
|
||||||
|
if (!validation.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid hanzi ID",
|
||||||
|
errors: validation.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the progress record
|
||||||
|
await prisma.userHanziProgress.delete({
|
||||||
|
where: {
|
||||||
|
userId_hanziId: {
|
||||||
|
userId: session.user.id,
|
||||||
|
hanziId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath("/progress")
|
||||||
|
revalidatePath("/dashboard")
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Hanzi progress reset successfully",
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Reset hanzi progress error:", error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Failed to reset hanzi progress",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
import { importHanzi } from "@/actions/admin"
|
import { importHanzi } from "@/actions/admin"
|
||||||
import type { ImportResult } from "@/lib/import/types"
|
import type { ImportResult } from "@/lib/import/types"
|
||||||
|
|
||||||
@@ -79,7 +80,18 @@ export default function AdminImportPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||||
<h1 className="text-3xl font-bold mb-6">Import Hanzi Data</h1>
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold mb-2">Import Hanzi Data</h1>
|
||||||
|
<div className="flex gap-4 text-sm">
|
||||||
|
<span className="text-blue-600 dark:text-blue-400 font-semibold">Import</span>
|
||||||
|
<Link
|
||||||
|
href="/admin/initialize"
|
||||||
|
className="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
Initialize
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
|
||||||
<h2 className="text-xl font-semibold mb-4">Import Data</h2>
|
<h2 className="text-xl font-semibold mb-4">Import Data</h2>
|
||||||
|
|||||||
305
src/app/(admin)/admin/initialize/page.tsx
Normal file
305
src/app/(admin)/admin/initialize/page.tsx
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { getInitializationFiles } from "@/actions/admin"
|
||||||
|
|
||||||
|
interface ProgressData {
|
||||||
|
percent: number
|
||||||
|
current: number
|
||||||
|
total: number
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompleteData {
|
||||||
|
imported: number
|
||||||
|
collectionsCreated: number
|
||||||
|
hanziAddedToCollections: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminInitializePage() {
|
||||||
|
const [availableFiles, setAvailableFiles] = useState<string[]>([])
|
||||||
|
const [selectedFiles, setSelectedFiles] = useState<string[]>([])
|
||||||
|
const [cleanData, setCleanData] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [progress, setProgress] = useState<ProgressData | null>(null)
|
||||||
|
const [result, setResult] = useState<CompleteData | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Load available files on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadFiles = async () => {
|
||||||
|
const response = await getInitializationFiles()
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setAvailableFiles(response.data)
|
||||||
|
// Auto-select complete.json if available
|
||||||
|
if (response.data.includes("complete.json")) {
|
||||||
|
setSelectedFiles(["complete.json"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadFiles()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Debug: Log when progress changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (progress) {
|
||||||
|
console.log("Progress state changed in UI:", progress)
|
||||||
|
}
|
||||||
|
}, [progress])
|
||||||
|
|
||||||
|
const toggleFileSelection = (fileName: string) => {
|
||||||
|
setSelectedFiles(prev =>
|
||||||
|
prev.includes(fileName)
|
||||||
|
? prev.filter(f => f !== fileName)
|
||||||
|
: [...prev, fileName]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (selectedFiles.length === 0) {
|
||||||
|
setError("Please select at least one file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setResult(null)
|
||||||
|
setProgress({ percent: 0, current: 0, total: 0, message: "Starting..." })
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create EventSource connection to SSE endpoint
|
||||||
|
const response = await fetch("/api/admin/initialize", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ fileNames: selectedFiles, cleanData }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
|
||||||
|
if (!reader) {
|
||||||
|
throw new Error("No response body")
|
||||||
|
}
|
||||||
|
|
||||||
|
let buffer = ""
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
const chunk = decoder.decode(value, { stream: true })
|
||||||
|
console.log("Received chunk:", chunk.substring(0, 100)) // Debug: show first 100 chars
|
||||||
|
|
||||||
|
buffer += chunk
|
||||||
|
const lines = buffer.split("\n\n")
|
||||||
|
buffer = lines.pop() || ""
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue
|
||||||
|
|
||||||
|
const eventMatch = line.match(/^event: (.+)$/)
|
||||||
|
const dataMatch = line.match(/^data: (.+)$/m)
|
||||||
|
|
||||||
|
if (eventMatch && dataMatch) {
|
||||||
|
const event = eventMatch[1]
|
||||||
|
const data = JSON.parse(dataMatch[1])
|
||||||
|
|
||||||
|
console.log("SSE Event:", event, data) // Debug logging
|
||||||
|
|
||||||
|
if (event === "progress") {
|
||||||
|
setProgress({ ...data }) // Create new object to force re-render
|
||||||
|
console.log("Updated progress state:", data)
|
||||||
|
} else if (event === "complete") {
|
||||||
|
setResult(data)
|
||||||
|
setProgress({ percent: 100, current: data.imported, total: data.imported, message: "Complete!" })
|
||||||
|
setLoading(false)
|
||||||
|
console.log("Completed!")
|
||||||
|
} else if (event === "error") {
|
||||||
|
setError(data.message)
|
||||||
|
setLoading(false)
|
||||||
|
console.log("Error:", data.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "An error occurred")
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold mb-2">Initialize Database</h1>
|
||||||
|
<div className="flex gap-4 text-sm">
|
||||||
|
<Link
|
||||||
|
href="/admin/import"
|
||||||
|
className="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</Link>
|
||||||
|
<span className="text-blue-600 dark:text-blue-400 font-semibold">Initialize</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-yellow-800 dark:text-yellow-200 mb-2">
|
||||||
|
⚠️ Warning
|
||||||
|
</h3>
|
||||||
|
<p className="text-yellow-700 dark:text-yellow-300">
|
||||||
|
This operation will import all hanzi from the selected files and create HSK level collections.
|
||||||
|
{cleanData && (
|
||||||
|
<span className="block mt-2 font-semibold">
|
||||||
|
With "Clean data" enabled, ALL existing hanzi, collections, and user progress will be PERMANENTLY DELETED before import.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Initialization Settings</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">
|
||||||
|
Select Files (select multiple)
|
||||||
|
</label>
|
||||||
|
<div className="border border-gray-300 dark:border-gray-600 rounded-md p-3 max-h-64 overflow-y-auto">
|
||||||
|
{availableFiles.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500">No files available in data/initialization/</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{availableFiles.map(fileName => (
|
||||||
|
<label key={fileName} className="flex items-center hover:bg-gray-50 dark:hover:bg-gray-700 p-2 rounded cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedFiles.includes(fileName)}
|
||||||
|
onChange={() => toggleFileSelection(fileName)}
|
||||||
|
disabled={loading}
|
||||||
|
className="mr-3 h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{fileName}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedFiles.length > 0 && (
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2">
|
||||||
|
{selectedFiles.length} file(s) selected: {selectedFiles.join(", ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={cleanData}
|
||||||
|
onChange={(e) => setCleanData(e.target.checked)}
|
||||||
|
disabled={loading}
|
||||||
|
className="mr-2 h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
Clean data before import (Delete all existing data)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1 ml-6">
|
||||||
|
Warning: This will delete all hanzi, collections, user progress, and learning sessions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || selectedFiles.length === 0}
|
||||||
|
className={`w-full py-2 px-4 rounded-md font-medium ${
|
||||||
|
loading || selectedFiles.length === 0
|
||||||
|
? "bg-gray-300 dark:bg-gray-600 cursor-not-allowed"
|
||||||
|
: cleanData
|
||||||
|
? "bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
: "bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{loading ? "Initializing..." : cleanData ? "Clean & Initialize Database" : "Initialize Database"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{progress && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6" key={`progress-${progress.percent}-${progress.current}`}>
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Progress</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-sm mb-1">
|
||||||
|
<span>{progress.message}</span>
|
||||||
|
<span className="font-semibold">{progress.percent}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-4 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-blue-600 h-4 rounded-full transition-all duration-300 ease-out"
|
||||||
|
style={{ width: `${progress.percent}%` }}
|
||||||
|
key={progress.percent}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{progress.total > 0 && (
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{progress.current.toLocaleString()} / {progress.total.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-red-800 dark:text-red-200 mb-2">
|
||||||
|
Error
|
||||||
|
</h3>
|
||||||
|
<p className="text-red-700 dark:text-red-300">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
|
||||||
|
<h3 className="text-lg font-semibold text-green-800 dark:text-green-200 mb-2">
|
||||||
|
✓ Initialization Complete
|
||||||
|
</h3>
|
||||||
|
<div className="text-green-700 dark:text-green-300 space-y-2">
|
||||||
|
<p>
|
||||||
|
<strong>Hanzi imported:</strong> {result.imported.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Collections created:</strong> {result.collectionsCreated}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Hanzi added to collections:</strong> {result.hanziAddedToCollections.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mt-6">
|
||||||
|
<h3 className="text-lg font-semibold text-blue-800 dark:text-blue-200 mb-2">
|
||||||
|
How It Works
|
||||||
|
</h3>
|
||||||
|
<ul className="text-blue-700 dark:text-blue-300 space-y-2 list-disc list-inside">
|
||||||
|
<li>Reads hanzi data from selected JSON files in data/initialization/</li>
|
||||||
|
<li>Optionally cleans all existing data (hanzi, collections, progress)</li>
|
||||||
|
<li>Imports all hanzi with their forms, meanings, and transcriptions</li>
|
||||||
|
<li>Creates public collections for each unique HSK level found (e.g., "HSK new-1", "HSK old-3")</li>
|
||||||
|
<li>Adds each hanzi to its corresponding level collection(s)</li>
|
||||||
|
<li>Shows real-time progress updates during the entire process</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -313,32 +313,40 @@ export default function CollectionDetailPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canModify && (
|
<div className="flex gap-2">
|
||||||
<div className="flex gap-2">
|
<Link
|
||||||
<button
|
href={`/learn/${collection.id}`}
|
||||||
onClick={() => setShowAddModal(true)}
|
className="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 inline-block"
|
||||||
disabled={actionLoading}
|
>
|
||||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-400"
|
Start Learning
|
||||||
>
|
</Link>
|
||||||
Add Hanzi
|
{canModify && (
|
||||||
</button>
|
<>
|
||||||
{collection.hanziCount > 0 && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectionMode(!selectionMode)}
|
onClick={() => setShowAddModal(true)}
|
||||||
className="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700"
|
disabled={actionLoading}
|
||||||
|
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-400"
|
||||||
>
|
>
|
||||||
{selectionMode ? "Cancel" : "Select"}
|
Add Hanzi
|
||||||
</button>
|
</button>
|
||||||
)}
|
{collection.hanziCount > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleDeleteCollection}
|
onClick={() => setSelectionMode(!selectionMode)}
|
||||||
disabled={actionLoading}
|
className="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700"
|
||||||
className="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 disabled:bg-gray-400"
|
>
|
||||||
>
|
{selectionMode ? "Cancel" : "Select"}
|
||||||
Delete
|
</button>
|
||||||
</button>
|
)}
|
||||||
</div>
|
<button
|
||||||
)}
|
onClick={handleDeleteCollection}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 disabled:bg-gray-400"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectionMode && selectedHanziIds.size > 0 && (
|
{selectionMode && selectedHanziIds.size > 0 && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { auth } from '@/lib/auth'
|
import { auth } from '@/lib/auth'
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import { logout } from '@/actions/auth'
|
import { logout } from '@/actions/auth'
|
||||||
|
import { getStatistics, getLearningSessions } from '@/actions/progress'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
async function logoutAction() {
|
async function logoutAction() {
|
||||||
@@ -18,6 +19,14 @@ export default async function DashboardPage() {
|
|||||||
|
|
||||||
const user = session.user as any
|
const user = session.user as any
|
||||||
|
|
||||||
|
// Get dashboard statistics
|
||||||
|
const statsResult = await getStatistics()
|
||||||
|
const stats = statsResult.success ? statsResult.data : null
|
||||||
|
|
||||||
|
// Get recent learning sessions
|
||||||
|
const sessionsResult = await getLearningSessions(5)
|
||||||
|
const recentSessions = sessionsResult.success ? sessionsResult.data : []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
<nav className="bg-white dark:bg-gray-800 shadow">
|
<nav className="bg-white dark:bg-gray-800 shadow">
|
||||||
@@ -39,6 +48,12 @@ export default async function DashboardPage() {
|
|||||||
>
|
>
|
||||||
Search Hanzi
|
Search Hanzi
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/progress"
|
||||||
|
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
Progress
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/settings"
|
href="/settings"
|
||||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||||
@@ -46,12 +61,20 @@ export default async function DashboardPage() {
|
|||||||
Settings
|
Settings
|
||||||
</Link>
|
</Link>
|
||||||
{(user.role === 'ADMIN' || user.role === 'MODERATOR') && (
|
{(user.role === 'ADMIN' || user.role === 'MODERATOR') && (
|
||||||
<Link
|
<>
|
||||||
href="/admin/import"
|
<Link
|
||||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
href="/admin/import"
|
||||||
>
|
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||||
Import
|
>
|
||||||
</Link>
|
Import
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/admin/initialize"
|
||||||
|
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
Initialize
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
@@ -87,10 +110,10 @@ export default async function DashboardPage() {
|
|||||||
Due Cards
|
Due Cards
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-2">
|
<p className="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-2">
|
||||||
0
|
{stats?.dueNow || 0}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
No cards due right now
|
{stats?.dueNow === 0 ? "No cards due right now" : `${stats?.dueToday || 0} due today`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -99,10 +122,10 @@ export default async function DashboardPage() {
|
|||||||
Total Learned
|
Total Learned
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-3xl font-bold text-green-600 dark:text-green-400 mb-2">
|
<p className="text-3xl font-bold text-green-600 dark:text-green-400 mb-2">
|
||||||
0
|
{stats?.totalLearned || 0}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
Characters mastered
|
{stats?.streak ? `${stats.streak} day streak!` : "Characters in progress"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -111,7 +134,7 @@ export default async function DashboardPage() {
|
|||||||
Daily Goal
|
Daily Goal
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-3xl font-bold text-purple-600 dark:text-purple-400 mb-2">
|
<p className="text-3xl font-bold text-purple-600 dark:text-purple-400 mb-2">
|
||||||
0/50
|
{stats?.reviewedToday || 0}/{stats?.dailyGoal || 50}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
Cards reviewed today
|
Cards reviewed today
|
||||||
@@ -124,6 +147,17 @@ export default async function DashboardPage() {
|
|||||||
Quick Actions
|
Quick Actions
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Link
|
||||||
|
href="/learn/all"
|
||||||
|
className="bg-white dark:bg-gray-800 p-4 rounded-lg hover:shadow-md transition-shadow border-2 border-blue-500"
|
||||||
|
>
|
||||||
|
<h4 className="font-semibold text-gray-900 dark:text-white mb-1">
|
||||||
|
Start Learning
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Begin a learning session with all cards
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/collections"
|
href="/collections"
|
||||||
className="bg-white dark:bg-gray-800 p-4 rounded-lg hover:shadow-md transition-shadow"
|
className="bg-white dark:bg-gray-800 p-4 rounded-lg hover:shadow-md transition-shadow"
|
||||||
@@ -135,17 +169,6 @@ export default async function DashboardPage() {
|
|||||||
View and manage your hanzi collections
|
View and manage your hanzi collections
|
||||||
</p>
|
</p>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
|
||||||
href="/collections/new"
|
|
||||||
className="bg-white dark:bg-gray-800 p-4 rounded-lg hover:shadow-md transition-shadow"
|
|
||||||
>
|
|
||||||
<h4 className="font-semibold text-gray-900 dark:text-white mb-1">
|
|
||||||
Create Collection
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
Start a new hanzi collection
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
<Link
|
<Link
|
||||||
href="/hanzi"
|
href="/hanzi"
|
||||||
className="bg-white dark:bg-gray-800 p-4 rounded-lg hover:shadow-md transition-shadow"
|
className="bg-white dark:bg-gray-800 p-4 rounded-lg hover:shadow-md transition-shadow"
|
||||||
@@ -158,10 +181,53 @@ export default async function DashboardPage() {
|
|||||||
</p>
|
</p>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-blue-800 dark:text-blue-400 mt-4">
|
|
||||||
More features coming soon: Learning sessions, progress tracking, and more!
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Activity */}
|
||||||
|
{recentSessions && recentSessions.length > 0 && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
Recent Activity
|
||||||
|
</h3>
|
||||||
|
<Link
|
||||||
|
href="/progress"
|
||||||
|
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
|
>
|
||||||
|
View All
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||||
|
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{recentSessions.map((session) => (
|
||||||
|
<div key={session.id} className="p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{session.collectionName}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{session.cardsReviewed} cards • {session.accuracyPercent}% accuracy
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{new Date(session.startedAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{new Date(session.startedAt).toLocaleTimeString([], {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
347
src/app/(app)/learn/[collectionId]/page.tsx
Normal file
347
src/app/(app)/learn/[collectionId]/page.tsx
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react"
|
||||||
|
import { useParams, useRouter } from "next/navigation"
|
||||||
|
import { startLearningSession, submitAnswer, endSession } from "@/actions/learning"
|
||||||
|
|
||||||
|
interface Card {
|
||||||
|
hanziId: string
|
||||||
|
simplified: string
|
||||||
|
options: string[]
|
||||||
|
correctPinyin: string
|
||||||
|
meaning: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionSummary {
|
||||||
|
totalCards: number
|
||||||
|
correctCount: number
|
||||||
|
incorrectCount: number
|
||||||
|
accuracyPercent: number
|
||||||
|
durationMinutes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LearnPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const router = useRouter()
|
||||||
|
const collectionId = params.collectionId as string
|
||||||
|
|
||||||
|
// Session state
|
||||||
|
const [sessionId, setSessionId] = useState<string | null>(null)
|
||||||
|
const [cards, setCards] = useState<Card[]>([])
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Card state
|
||||||
|
const [selectedOption, setSelectedOption] = useState<number | null>(null)
|
||||||
|
const [showFeedback, setShowFeedback] = useState(false)
|
||||||
|
const [isCorrect, setIsCorrect] = useState(false)
|
||||||
|
const [answerStartTime, setAnswerStartTime] = useState<number>(Date.now())
|
||||||
|
|
||||||
|
// Summary state
|
||||||
|
const [showSummary, setShowSummary] = useState(false)
|
||||||
|
const [summary, setSummary] = useState<SessionSummary | null>(null)
|
||||||
|
|
||||||
|
const currentCard = cards[currentIndex]
|
||||||
|
const progress = ((currentIndex / cards.length) * 100) || 0
|
||||||
|
|
||||||
|
// Start learning session
|
||||||
|
useEffect(() => {
|
||||||
|
const startSession = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
const collectionIdParam = collectionId === "all" ? undefined : collectionId
|
||||||
|
|
||||||
|
const result = await startLearningSession(collectionIdParam)
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setSessionId(result.data.sessionId)
|
||||||
|
setCards(result.data.cards)
|
||||||
|
setAnswerStartTime(Date.now())
|
||||||
|
} else {
|
||||||
|
setError(result.message || "Failed to start learning session")
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
startSession()
|
||||||
|
}, [collectionId])
|
||||||
|
|
||||||
|
// Handle answer selection
|
||||||
|
const handleSelectAnswer = useCallback((index: number) => {
|
||||||
|
if (showFeedback) return // Prevent changing answer after submission
|
||||||
|
|
||||||
|
setSelectedOption(index)
|
||||||
|
}, [showFeedback])
|
||||||
|
|
||||||
|
// Submit answer
|
||||||
|
const handleSubmitAnswer = useCallback(async () => {
|
||||||
|
if (selectedOption === null || !currentCard || !sessionId) return
|
||||||
|
if (showFeedback) return // Already submitted
|
||||||
|
|
||||||
|
const selectedPinyin = currentCard.options[selectedOption]
|
||||||
|
const correct = selectedPinyin === currentCard.correctPinyin
|
||||||
|
const timeSpentMs = Date.now() - answerStartTime
|
||||||
|
|
||||||
|
setIsCorrect(correct)
|
||||||
|
setShowFeedback(true)
|
||||||
|
|
||||||
|
// Submit to backend
|
||||||
|
await submitAnswer(
|
||||||
|
sessionId,
|
||||||
|
currentCard.hanziId,
|
||||||
|
selectedPinyin,
|
||||||
|
correct,
|
||||||
|
timeSpentMs
|
||||||
|
)
|
||||||
|
}, [selectedOption, currentCard, sessionId, showFeedback, answerStartTime])
|
||||||
|
|
||||||
|
// Continue to next card or end session
|
||||||
|
const handleContinue = useCallback(async () => {
|
||||||
|
if (currentIndex < cards.length - 1) {
|
||||||
|
setCurrentIndex(prev => prev + 1)
|
||||||
|
setSelectedOption(null)
|
||||||
|
setShowFeedback(false)
|
||||||
|
setAnswerStartTime(Date.now())
|
||||||
|
} else {
|
||||||
|
// End session and show summary
|
||||||
|
if (sessionId) {
|
||||||
|
const result = await endSession(sessionId)
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setSummary(result.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setShowSummary(true)
|
||||||
|
}
|
||||||
|
}, [currentIndex, cards.length, sessionId])
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyPress = (e: KeyboardEvent) => {
|
||||||
|
// Numbers 1-4 for answer selection
|
||||||
|
if (["1", "2", "3", "4"].includes(e.key)) {
|
||||||
|
const index = parseInt(e.key) - 1
|
||||||
|
if (index < currentCard?.options.length) {
|
||||||
|
if (!showFeedback) {
|
||||||
|
handleSelectAnswer(index)
|
||||||
|
// Auto-submit after selection
|
||||||
|
setTimeout(() => {
|
||||||
|
handleSubmitAnswer()
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Space to continue
|
||||||
|
if (e.key === " " && showFeedback) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleContinue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyPress)
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyPress)
|
||||||
|
}, [currentCard, showFeedback, handleSelectAnswer, handleSubmitAnswer, handleContinue])
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||||
|
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading cards...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8 max-w-md">
|
||||||
|
<div className="text-red-600 dark:text-red-400 text-center mb-4">
|
||||||
|
<svg className="w-16 h-16 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-center mb-4">No Cards Available</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 text-center mb-6">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/collections")}
|
||||||
|
className="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 text-white rounded-md font-medium"
|
||||||
|
>
|
||||||
|
Go to Collections
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary screen
|
||||||
|
if (showSummary && summary) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8 max-w-md w-full">
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<div className="text-6xl mb-4">🎉</div>
|
||||||
|
<h2 className="text-3xl font-bold mb-2">Session Complete!</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">Great work!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 mb-6">
|
||||||
|
<div className="flex justify-between items-center p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Total Cards</span>
|
||||||
|
<span className="text-2xl font-bold">{summary.totalCards}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||||
|
<span className="text-green-600 dark:text-green-400">Correct</span>
|
||||||
|
<span className="text-2xl font-bold text-green-600 dark:text-green-400">{summary.correctCount}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center p-4 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||||
|
<span className="text-red-600 dark:text-red-400">Incorrect</span>
|
||||||
|
<span className="text-2xl font-bold text-red-600 dark:text-red-400">{summary.incorrectCount}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||||
|
<span className="text-blue-600 dark:text-blue-400">Accuracy</span>
|
||||||
|
<span className="text-2xl font-bold text-blue-600 dark:text-blue-400">{summary.accuracyPercent}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Duration</span>
|
||||||
|
<span className="text-2xl font-bold">{summary.durationMinutes} min</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 text-white rounded-md font-medium"
|
||||||
|
>
|
||||||
|
Start New Session
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/dashboard")}
|
||||||
|
className="w-full py-3 px-4 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-md font-medium"
|
||||||
|
>
|
||||||
|
Back to Dashboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Learning card screen
|
||||||
|
if (!currentCard) {
|
||||||
|
return <div>No cards available</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 py-4">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Card {currentIndex + 1} of {cards.length}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-blue-600 dark:text-blue-400">
|
||||||
|
{Math.round(progress)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main card area */}
|
||||||
|
<div className="max-w-4xl mx-auto px-4 py-12">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-8 relative overflow-hidden">
|
||||||
|
{/* Feedback overlay */}
|
||||||
|
{showFeedback && (
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 flex items-center justify-center z-10 ${
|
||||||
|
isCorrect
|
||||||
|
? "bg-green-500/10 dark:bg-green-500/20"
|
||||||
|
: "bg-red-500/10 dark:bg-red-500/20"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className={`text-8xl mb-4 ${isCorrect ? "text-green-600" : "text-red-600"}`}>
|
||||||
|
{isCorrect ? "✓" : "✗"}
|
||||||
|
</div>
|
||||||
|
<p className={`text-2xl font-bold mb-2 ${isCorrect ? "text-green-600" : "text-red-600"}`}>
|
||||||
|
{isCorrect ? "Correct!" : "Incorrect"}
|
||||||
|
</p>
|
||||||
|
{!isCorrect && (
|
||||||
|
<p className="text-lg text-gray-700 dark:text-gray-300">
|
||||||
|
Correct answer: <span className="font-bold">{currentCard.correctPinyin}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{currentCard.meaning && (
|
||||||
|
<p className="text-lg text-gray-600 dark:text-gray-400 mt-3 italic">
|
||||||
|
{currentCard.meaning}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleContinue}
|
||||||
|
className="mt-6 py-2 px-6 bg-blue-600 hover:bg-blue-700 text-white rounded-md font-medium"
|
||||||
|
>
|
||||||
|
Continue (Space)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hanzi display */}
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<div className="text-9xl font-bold mb-4">{currentCard.simplified}</div>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">Select the correct pinyin</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Answer options in 2x2 grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 max-w-2xl mx-auto">
|
||||||
|
{currentCard.options.map((option, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => {
|
||||||
|
handleSelectAnswer(index)
|
||||||
|
// Auto-submit after a brief delay
|
||||||
|
setTimeout(() => handleSubmitAnswer(), 100)
|
||||||
|
}}
|
||||||
|
disabled={showFeedback}
|
||||||
|
className={`p-6 text-2xl font-medium rounded-lg transition-all ${
|
||||||
|
selectedOption === index && !showFeedback
|
||||||
|
? "bg-blue-600 text-white scale-105"
|
||||||
|
: showFeedback && option === currentCard.correctPinyin
|
||||||
|
? "bg-green-600 text-white"
|
||||||
|
: showFeedback && selectedOption === index
|
||||||
|
? "bg-red-600 text-white"
|
||||||
|
: "bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100"
|
||||||
|
} ${showFeedback ? "cursor-not-allowed" : "cursor-pointer"}`}
|
||||||
|
>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400 mb-1">{index + 1}</div>
|
||||||
|
{option}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Keyboard shortcuts hint */}
|
||||||
|
<div className="mt-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<p>Press 1-4 to select • Space to continue</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
316
src/app/(app)/progress/page.tsx
Normal file
316
src/app/(app)/progress/page.tsx
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from "recharts"
|
||||||
|
import { getUserProgress, getLearningSessions } from "@/actions/progress"
|
||||||
|
|
||||||
|
export default function ProgressPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [progressData, setProgressData] = useState<any>(null)
|
||||||
|
const [sessions, setSessions] = useState<any[]>([])
|
||||||
|
const [dateRange, setDateRange] = useState("30") // days
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadProgress()
|
||||||
|
}, [dateRange])
|
||||||
|
|
||||||
|
const loadProgress = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
const days = parseInt(dateRange)
|
||||||
|
const endDate = new Date()
|
||||||
|
const startDate = new Date()
|
||||||
|
startDate.setDate(startDate.getDate() - days)
|
||||||
|
|
||||||
|
const [progressResult, sessionsResult] = await Promise.all([
|
||||||
|
getUserProgress(startDate, endDate),
|
||||||
|
getLearningSessions(20),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (progressResult.success) {
|
||||||
|
setProgressData(progressResult.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionsResult.success) {
|
||||||
|
setSessions(sessionsResult.data || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||||
|
<div className="text-gray-600 dark:text-gray-400">Loading progress...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData = progressData?.dailyActivity || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<nav className="bg-white dark:bg-gray-800 shadow">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between h-16 items-center">
|
||||||
|
<Link href="/dashboard">
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white cursor-pointer">
|
||||||
|
MemoHanzi <span className="text-sm font-normal text-gray-500">记汉字</span>
|
||||||
|
</h1>
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/collections"
|
||||||
|
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
Collections
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/hanzi"
|
||||||
|
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
Search Hanzi
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
Learning Progress
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Track your learning journey and review statistics
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date Range Selector */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 mr-3">
|
||||||
|
Time Period:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={dateRange}
|
||||||
|
onChange={(e) => setDateRange(e.target.value)}
|
||||||
|
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="7">Last 7 days</option>
|
||||||
|
<option value="30">Last 30 days</option>
|
||||||
|
<option value="90">Last 90 days</option>
|
||||||
|
<option value="365">Last year</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
Cards Reviewed
|
||||||
|
</h3>
|
||||||
|
<p className="text-3xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{progressData?.cardsReviewed || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
Accuracy
|
||||||
|
</h3>
|
||||||
|
<p className="text-3xl font-bold text-green-600 dark:text-green-400">
|
||||||
|
{progressData?.accuracyPercent || 0}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
Total Cards
|
||||||
|
</h3>
|
||||||
|
<p className="text-3xl font-bold text-purple-600 dark:text-purple-400">
|
||||||
|
{progressData?.totalCards || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
Avg Session
|
||||||
|
</h3>
|
||||||
|
<p className="text-3xl font-bold text-orange-600 dark:text-orange-400">
|
||||||
|
{progressData?.averageSessionLength || 0}m
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Daily Activity Chart */}
|
||||||
|
{chartData.length > 0 && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-8">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Daily Activity
|
||||||
|
</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
stroke="#9CA3AF"
|
||||||
|
tick={{ fill: '#9CA3AF' }}
|
||||||
|
/>
|
||||||
|
<YAxis stroke="#9CA3AF" tick={{ fill: '#9CA3AF' }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#1F2937',
|
||||||
|
border: '1px solid #374151',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
color: '#F3F4F6'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ color: '#9CA3AF' }} />
|
||||||
|
<Bar dataKey="correct" fill="#10B981" name="Correct" stackId="a" />
|
||||||
|
<Bar dataKey="incorrect" fill="#EF4444" name="Incorrect" stackId="a" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Accuracy Trend Chart */}
|
||||||
|
{chartData.length > 0 && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-8">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Accuracy Trend
|
||||||
|
</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<LineChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
stroke="#9CA3AF"
|
||||||
|
tick={{ fill: '#9CA3AF' }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke="#9CA3AF"
|
||||||
|
tick={{ fill: '#9CA3AF' }}
|
||||||
|
domain={[0, 100]}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#1F2937',
|
||||||
|
border: '1px solid #374151',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
color: '#F3F4F6'
|
||||||
|
}}
|
||||||
|
formatter={(value: any, name: any) => {
|
||||||
|
if (name === "Accuracy") return [`${value.toFixed(1)}%`, name]
|
||||||
|
return [value, name]
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ color: '#9CA3AF' }} />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey={(data: any) => {
|
||||||
|
const total = data.correct + data.incorrect
|
||||||
|
return total > 0 ? (data.correct / total) * 100 : 0
|
||||||
|
}}
|
||||||
|
stroke="#3B82F6"
|
||||||
|
strokeWidth={2}
|
||||||
|
name="Accuracy"
|
||||||
|
dot={{ fill: '#3B82F6' }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Session History */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Recent Sessions
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Date
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Collection
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Cards
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Correct
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Incorrect
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Accuracy
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{sessions.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
No learning sessions yet. Start learning to see your progress!
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
sessions.map((session) => (
|
||||||
|
<tr key={session.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||||
|
{new Date(session.startedAt).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||||
|
{session.collectionName}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||||
|
{session.cardsReviewed}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-green-600 dark:text-green-400">
|
||||||
|
{session.correctAnswers}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-red-600 dark:text-red-400">
|
||||||
|
{session.incorrectAnswers}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||||
|
{session.accuracyPercent}%
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
330
src/app/api/admin/initialize/route.ts
Normal file
330
src/app/api/admin/initialize/route.ts
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
import { NextRequest } from "next/server"
|
||||||
|
import { isAdminOrModerator } from "@/lib/auth"
|
||||||
|
import { auth } from "@/lib/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { parseHSKJson } from "@/lib/import/hsk-json-parser"
|
||||||
|
import type { ParsedHanzi } from "@/lib/import/types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSE endpoint for database initialization with real-time progress updates
|
||||||
|
*
|
||||||
|
* POST /api/admin/initialize
|
||||||
|
* Body: { fileNames: string[], cleanData: boolean }
|
||||||
|
*
|
||||||
|
* Returns Server-Sent Events stream with progress updates:
|
||||||
|
* - progress: { percent, current, total, message }
|
||||||
|
* - complete: { imported, collectionsCreated, hanziAddedToCollections }
|
||||||
|
* - error: { message }
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Check admin authorization
|
||||||
|
const isAuthorized = await isAdminOrModerator()
|
||||||
|
if (!isAuthorized) {
|
||||||
|
return new Response("Unauthorized", { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const { fileNames, cleanData } = body as { fileNames: string[]; cleanData: boolean }
|
||||||
|
|
||||||
|
if (!fileNames || fileNames.length === 0) {
|
||||||
|
return new Response("No files specified", { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create SSE stream with proper flushing
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const { readable, writable } = new TransformStream()
|
||||||
|
const writer = writable.getWriter()
|
||||||
|
|
||||||
|
// Start processing in background (don't await - let it run while streaming)
|
||||||
|
const processInitialization = async () => {
|
||||||
|
try {
|
||||||
|
// Helper to send SSE events with immediate flush
|
||||||
|
const sendEvent = async (event: string, data: any) => {
|
||||||
|
const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
|
||||||
|
await writer.write(encoder.encode(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Read and parse all files
|
||||||
|
await sendEvent("progress", { percent: 0, current: 0, total: 0, message: "Reading files..." })
|
||||||
|
|
||||||
|
const fs = await import("fs/promises")
|
||||||
|
const path = await import("path")
|
||||||
|
const allParsedHanzi: ParsedHanzi[] = []
|
||||||
|
let totalFiles = fileNames.length
|
||||||
|
let filesProcessed = 0
|
||||||
|
|
||||||
|
for (const fileName of fileNames) {
|
||||||
|
const filePath = path.join(process.cwd(), "data", "initialization", fileName)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileData = await fs.readFile(filePath, "utf-8")
|
||||||
|
const { result, data } = parseHSKJson(fileData)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
await sendEvent("error", { message: `Failed to parse ${fileName}: ${result.errors.join(", ")}` })
|
||||||
|
await writer.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
allParsedHanzi.push(...data)
|
||||||
|
filesProcessed++
|
||||||
|
|
||||||
|
await sendEvent("progress", {
|
||||||
|
percent: Math.round((filesProcessed / totalFiles) * 10),
|
||||||
|
current: filesProcessed,
|
||||||
|
total: totalFiles,
|
||||||
|
message: `Parsed ${fileName} (${data.length} hanzi)`,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
await sendEvent("error", { message: `Failed to read ${fileName}` })
|
||||||
|
await writer.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalHanzi = allParsedHanzi.length
|
||||||
|
await sendEvent("progress", {
|
||||||
|
percent: 10,
|
||||||
|
current: 0,
|
||||||
|
total: totalHanzi,
|
||||||
|
message: `Ready to import ${totalHanzi} hanzi`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Step 2: Clean data if requested
|
||||||
|
if (cleanData) {
|
||||||
|
await sendEvent("progress", { percent: 15, current: 0, total: totalHanzi, message: "Cleaning existing data..." })
|
||||||
|
|
||||||
|
await prisma.collectionItem.deleteMany({})
|
||||||
|
await prisma.collection.deleteMany({})
|
||||||
|
await prisma.userHanziProgress.deleteMany({})
|
||||||
|
await prisma.sessionReview.deleteMany({})
|
||||||
|
await prisma.learningSession.deleteMany({})
|
||||||
|
await prisma.hanziMeaning.deleteMany({})
|
||||||
|
await prisma.hanziTranscription.deleteMany({})
|
||||||
|
await prisma.hanziClassifier.deleteMany({})
|
||||||
|
await prisma.hanziForm.deleteMany({})
|
||||||
|
await prisma.hanziHSKLevel.deleteMany({})
|
||||||
|
await prisma.hanziPOS.deleteMany({})
|
||||||
|
await prisma.hanzi.deleteMany({})
|
||||||
|
|
||||||
|
await sendEvent("progress", { percent: 20, current: 0, total: totalHanzi, message: "Data cleaned" })
|
||||||
|
} else {
|
||||||
|
await sendEvent("progress", { percent: 20, current: 0, total: totalHanzi, message: "Skipping clean" })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Get or create English language
|
||||||
|
let englishLanguage = await prisma.language.findUnique({ where: { code: "en" } })
|
||||||
|
if (!englishLanguage) {
|
||||||
|
englishLanguage = await prisma.language.create({
|
||||||
|
data: { code: "en", name: "English", nativeName: "English", isActive: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Import hanzi with progress updates
|
||||||
|
let imported = 0
|
||||||
|
const importStartPercent = 20
|
||||||
|
const importEndPercent = 70
|
||||||
|
|
||||||
|
for (const hanzi of allParsedHanzi) {
|
||||||
|
// Import hanzi (same logic as saveParsedHanzi but inline)
|
||||||
|
const hanziRecord = await prisma.hanzi.upsert({
|
||||||
|
where: { simplified: hanzi.simplified },
|
||||||
|
update: { radical: hanzi.radical, frequency: hanzi.frequency },
|
||||||
|
create: { simplified: hanzi.simplified, radical: hanzi.radical, frequency: hanzi.frequency },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete existing related data
|
||||||
|
await prisma.hanziForm.deleteMany({ where: { hanziId: hanziRecord.id } })
|
||||||
|
await prisma.hanziHSKLevel.deleteMany({ where: { hanziId: hanziRecord.id } })
|
||||||
|
await prisma.hanziPOS.deleteMany({ where: { hanziId: hanziRecord.id } })
|
||||||
|
|
||||||
|
// Create HSK levels
|
||||||
|
if (hanzi.hskLevels.length > 0) {
|
||||||
|
await prisma.hanziHSKLevel.createMany({
|
||||||
|
data: hanzi.hskLevels.map(level => ({ hanziId: hanziRecord.id, level })),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create parts of speech
|
||||||
|
if (hanzi.partsOfSpeech.length > 0) {
|
||||||
|
await prisma.hanziPOS.createMany({
|
||||||
|
data: hanzi.partsOfSpeech.map(pos => ({ hanziId: hanziRecord.id, pos })),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create forms with transcriptions, meanings, classifiers
|
||||||
|
for (const form of hanzi.forms) {
|
||||||
|
const formRecord = await prisma.hanziForm.create({
|
||||||
|
data: { hanziId: hanziRecord.id, traditional: form.traditional, isDefault: form.isDefault },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (form.transcriptions.length > 0) {
|
||||||
|
await prisma.hanziTranscription.createMany({
|
||||||
|
data: form.transcriptions.map(trans => ({
|
||||||
|
formId: formRecord.id,
|
||||||
|
type: trans.type,
|
||||||
|
value: trans.value,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.meanings.length > 0) {
|
||||||
|
await prisma.hanziMeaning.createMany({
|
||||||
|
data: form.meanings.map(meaning => ({
|
||||||
|
formId: formRecord.id,
|
||||||
|
languageId: englishLanguage!.id,
|
||||||
|
meaning: meaning.meaning,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.classifiers.length > 0) {
|
||||||
|
await prisma.hanziClassifier.createMany({
|
||||||
|
data: form.classifiers.map(classifier => ({
|
||||||
|
formId: formRecord.id,
|
||||||
|
classifier: classifier,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
imported++
|
||||||
|
|
||||||
|
// Send progress update every 50 hanzi or on last one
|
||||||
|
if (imported % 50 === 0 || imported === totalHanzi) {
|
||||||
|
const percent = importStartPercent + Math.round(((imported / totalHanzi) * (importEndPercent - importStartPercent)))
|
||||||
|
await sendEvent("progress", {
|
||||||
|
percent,
|
||||||
|
current: imported,
|
||||||
|
total: totalHanzi,
|
||||||
|
message: `Importing hanzi: ${imported}/${totalHanzi}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendEvent("progress", { percent: 70, current: totalHanzi, total: totalHanzi, message: "All hanzi imported" })
|
||||||
|
|
||||||
|
// Step 5: Extract unique HSK levels and create collections
|
||||||
|
await sendEvent("progress", { percent: 75, current: 0, total: 0, message: "Creating collections..." })
|
||||||
|
|
||||||
|
const uniqueLevels = new Set<string>()
|
||||||
|
allParsedHanzi.forEach(hanzi => {
|
||||||
|
hanzi.hskLevels.forEach(level => uniqueLevels.add(level))
|
||||||
|
})
|
||||||
|
|
||||||
|
const levelCollections = new Map<string, string>()
|
||||||
|
let collectionsCreated = 0
|
||||||
|
|
||||||
|
for (const level of uniqueLevels) {
|
||||||
|
const existingCollection = await prisma.collection.findFirst({
|
||||||
|
where: { name: `HSK ${level}`, isPublic: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingCollection) {
|
||||||
|
levelCollections.set(level, existingCollection.id)
|
||||||
|
} else {
|
||||||
|
const newCollection = await prisma.collection.create({
|
||||||
|
data: {
|
||||||
|
name: `HSK ${level}`,
|
||||||
|
description: `HSK ${level} vocabulary collection`,
|
||||||
|
isPublic: true,
|
||||||
|
createdBy: session?.user?.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
levelCollections.set(level, newCollection.id)
|
||||||
|
collectionsCreated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendEvent("progress", {
|
||||||
|
percent: 80,
|
||||||
|
current: collectionsCreated,
|
||||||
|
total: uniqueLevels.size,
|
||||||
|
message: `Created ${collectionsCreated} collections`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Step 6: Add hanzi to collections
|
||||||
|
await sendEvent("progress", { percent: 85, current: 0, total: totalHanzi, message: "Adding hanzi to collections..." })
|
||||||
|
|
||||||
|
let hanziAddedToCollections = 0
|
||||||
|
let processedForCollections = 0
|
||||||
|
|
||||||
|
for (const hanzi of allParsedHanzi) {
|
||||||
|
const hanziRecord = await prisma.hanzi.findUnique({ where: { simplified: hanzi.simplified } })
|
||||||
|
if (!hanziRecord) continue
|
||||||
|
|
||||||
|
for (const level of hanzi.hskLevels) {
|
||||||
|
const collectionId = levelCollections.get(level)
|
||||||
|
if (!collectionId) continue
|
||||||
|
|
||||||
|
const existingItem = await prisma.collectionItem.findUnique({
|
||||||
|
where: { collectionId_hanziId: { collectionId, hanziId: hanziRecord.id } },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existingItem) {
|
||||||
|
const maxOrderIndex = await prisma.collectionItem.findFirst({
|
||||||
|
where: { collectionId },
|
||||||
|
orderBy: { orderIndex: "desc" },
|
||||||
|
select: { orderIndex: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.collectionItem.create({
|
||||||
|
data: {
|
||||||
|
collectionId,
|
||||||
|
hanziId: hanziRecord.id,
|
||||||
|
orderIndex: (maxOrderIndex?.orderIndex ?? -1) + 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
hanziAddedToCollections++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processedForCollections++
|
||||||
|
|
||||||
|
// Send progress update every 100 hanzi
|
||||||
|
if (processedForCollections % 100 === 0 || processedForCollections === totalHanzi) {
|
||||||
|
const percent = 85 + Math.round(((processedForCollections / totalHanzi) * 15))
|
||||||
|
await sendEvent("progress", {
|
||||||
|
percent,
|
||||||
|
current: processedForCollections,
|
||||||
|
total: totalHanzi,
|
||||||
|
message: `Adding to collections: ${processedForCollections}/${totalHanzi}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7: Complete
|
||||||
|
await sendEvent("complete", {
|
||||||
|
imported: totalHanzi,
|
||||||
|
collectionsCreated,
|
||||||
|
hanziAddedToCollections,
|
||||||
|
})
|
||||||
|
|
||||||
|
await writer.close()
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Initialization error:", error)
|
||||||
|
const message = `event: error\ndata: ${JSON.stringify({ message: "Initialization failed" })}\n\n`
|
||||||
|
await writer.write(encoder.encode(message))
|
||||||
|
await writer.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start processing (don't await - let it stream)
|
||||||
|
processInitialization()
|
||||||
|
|
||||||
|
return new Response(readable, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no", // Disable nginx buffering
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API error:", error)
|
||||||
|
return new Response("Internal server error", { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link"
|
||||||
|
import { auth } from "@/lib/auth"
|
||||||
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
|
export default async function Home() {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
// Redirect authenticated users to dashboard
|
||||||
|
if (session?.user) {
|
||||||
|
redirect("/dashboard")
|
||||||
|
}
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-b from-white to-gray-50 dark:from-gray-900 dark:to-black">
|
<div className="flex min-h-screen items-center justify-center bg-gradient-to-b from-white to-gray-50 dark:from-gray-900 dark:to-black">
|
||||||
<main className="flex flex-col items-center justify-center px-8 py-16 text-center max-w-4xl">
|
<main className="flex flex-col items-center justify-center px-8 py-16 text-center max-w-4xl">
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ describe("SM-2 Algorithm", () => {
|
|||||||
expect(INITIAL_PROGRESS.interval).toBe(1)
|
expect(INITIAL_PROGRESS.interval).toBe(1)
|
||||||
expect(INITIAL_PROGRESS.consecutiveCorrect).toBe(0)
|
expect(INITIAL_PROGRESS.consecutiveCorrect).toBe(0)
|
||||||
expect(INITIAL_PROGRESS.incorrectCount).toBe(0)
|
expect(INITIAL_PROGRESS.incorrectCount).toBe(0)
|
||||||
expect(INITIAL_PROGRESS.lastReviewDate).toBeNull()
|
|
||||||
expect(INITIAL_PROGRESS.nextReviewDate).toBeNull()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -51,7 +49,6 @@ describe("SM-2 Algorithm", () => {
|
|||||||
interval: 1,
|
interval: 1,
|
||||||
consecutiveCorrect: 1,
|
consecutiveCorrect: 1,
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
lastReviewDate: new Date("2025-01-01"),
|
|
||||||
nextReviewDate: new Date("2025-01-02"),
|
nextReviewDate: new Date("2025-01-02"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +66,6 @@ describe("SM-2 Algorithm", () => {
|
|||||||
interval: 6,
|
interval: 6,
|
||||||
consecutiveCorrect: 2,
|
consecutiveCorrect: 2,
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
lastReviewDate: new Date("2025-01-02"),
|
|
||||||
nextReviewDate: new Date("2025-01-08"),
|
nextReviewDate: new Date("2025-01-08"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +84,6 @@ describe("SM-2 Algorithm", () => {
|
|||||||
interval: 50,
|
interval: 50,
|
||||||
consecutiveCorrect: 5,
|
consecutiveCorrect: 5,
|
||||||
incorrectCount: 2,
|
incorrectCount: 2,
|
||||||
lastReviewDate: new Date("2025-01-01"),
|
|
||||||
nextReviewDate: new Date("2025-02-20"),
|
nextReviewDate: new Date("2025-02-20"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +125,6 @@ describe("SM-2 Algorithm", () => {
|
|||||||
interval: 365,
|
interval: 365,
|
||||||
consecutiveCorrect: 10,
|
consecutiveCorrect: 10,
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
lastReviewDate: new Date("2025-01-01"),
|
|
||||||
nextReviewDate: new Date("2026-01-01"),
|
nextReviewDate: new Date("2026-01-01"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +143,6 @@ describe("SM-2 Algorithm", () => {
|
|||||||
interval: 16,
|
interval: 16,
|
||||||
consecutiveCorrect: 3,
|
consecutiveCorrect: 3,
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
lastReviewDate: new Date("2025-01-01"),
|
|
||||||
nextReviewDate: new Date("2025-01-17"),
|
nextReviewDate: new Date("2025-01-17"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +158,6 @@ describe("SM-2 Algorithm", () => {
|
|||||||
interval: 16,
|
interval: 16,
|
||||||
consecutiveCorrect: 5,
|
consecutiveCorrect: 5,
|
||||||
incorrectCount: 1,
|
incorrectCount: 1,
|
||||||
lastReviewDate: new Date("2025-01-01"),
|
|
||||||
nextReviewDate: new Date("2025-01-17"),
|
nextReviewDate: new Date("2025-01-17"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +172,6 @@ describe("SM-2 Algorithm", () => {
|
|||||||
interval: 6,
|
interval: 6,
|
||||||
consecutiveCorrect: 2,
|
consecutiveCorrect: 2,
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
lastReviewDate: new Date("2025-01-01"),
|
|
||||||
nextReviewDate: new Date("2025-01-07"),
|
nextReviewDate: new Date("2025-01-07"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +186,6 @@ describe("SM-2 Algorithm", () => {
|
|||||||
interval: 1,
|
interval: 1,
|
||||||
consecutiveCorrect: 0,
|
consecutiveCorrect: 0,
|
||||||
incorrectCount: 5,
|
incorrectCount: 5,
|
||||||
lastReviewDate: new Date("2025-01-01"),
|
|
||||||
nextReviewDate: new Date("2025-01-02"),
|
nextReviewDate: new Date("2025-01-02"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +201,6 @@ describe("SM-2 Algorithm", () => {
|
|||||||
interval: 6,
|
interval: 6,
|
||||||
consecutiveCorrect: 2,
|
consecutiveCorrect: 2,
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
lastReviewDate: new Date("2025-01-01"),
|
|
||||||
nextReviewDate: new Date("2025-01-07"),
|
nextReviewDate: new Date("2025-01-07"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +215,6 @@ describe("SM-2 Algorithm", () => {
|
|||||||
interval: 6,
|
interval: 6,
|
||||||
consecutiveCorrect: 2,
|
consecutiveCorrect: 2,
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
lastReviewDate: new Date("2025-01-01"),
|
|
||||||
nextReviewDate: new Date("2025-01-07"),
|
nextReviewDate: new Date("2025-01-07"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +242,6 @@ describe("SM-2 Algorithm", () => {
|
|||||||
interval: 16,
|
interval: 16,
|
||||||
consecutiveCorrect: 3,
|
consecutiveCorrect: 3,
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
lastReviewDate: new Date("2025-01-01"),
|
|
||||||
nextReviewDate: new Date("2025-01-17"),
|
nextReviewDate: new Date("2025-01-17"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,7 +256,6 @@ describe("SM-2 Algorithm", () => {
|
|||||||
interval: result.interval,
|
interval: result.interval,
|
||||||
consecutiveCorrect: result.consecutiveCorrect,
|
consecutiveCorrect: result.consecutiveCorrect,
|
||||||
incorrectCount: result.incorrectCount,
|
incorrectCount: result.incorrectCount,
|
||||||
lastReviewDate: new Date("2025-01-17"),
|
|
||||||
nextReviewDate: result.nextReviewDate,
|
nextReviewDate: result.nextReviewDate,
|
||||||
}
|
}
|
||||||
result = calculateIncorrectAnswer(progress, new Date("2025-01-18"))
|
result = calculateIncorrectAnswer(progress, new Date("2025-01-18"))
|
||||||
@@ -282,7 +268,6 @@ describe("SM-2 Algorithm", () => {
|
|||||||
interval: result.interval,
|
interval: result.interval,
|
||||||
consecutiveCorrect: result.consecutiveCorrect,
|
consecutiveCorrect: result.consecutiveCorrect,
|
||||||
incorrectCount: result.incorrectCount,
|
incorrectCount: result.incorrectCount,
|
||||||
lastReviewDate: new Date("2025-01-18"),
|
|
||||||
nextReviewDate: result.nextReviewDate,
|
nextReviewDate: result.nextReviewDate,
|
||||||
}
|
}
|
||||||
result = calculateIncorrectAnswer(progress, new Date("2025-01-19"))
|
result = calculateIncorrectAnswer(progress, new Date("2025-01-19"))
|
||||||
@@ -301,25 +286,25 @@ describe("SM-2 Algorithm", () => {
|
|||||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"), // Due
|
nextReviewDate: new Date("2025-01-14T10:00:00Z"), // Due
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 1,
|
consecutiveCorrect: 1,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "2",
|
id: "2",
|
||||||
nextReviewDate: new Date("2025-01-16T10:00:00Z"), // Not due
|
nextReviewDate: new Date("2025-01-16T10:00:00Z"), // Not due
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 1,
|
consecutiveCorrect: 1,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "3",
|
id: "3",
|
||||||
nextReviewDate: new Date("2025-01-13T10:00:00Z"), // Due
|
nextReviewDate: new Date("2025-01-13T10:00:00Z"), // Due
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 1,
|
consecutiveCorrect: 1,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const selected = selectCardsForSession(cards, 10, now)
|
const selected = selectCardsForSession(cards, 10, now, false)
|
||||||
|
|
||||||
expect(selected.length).toBe(2)
|
expect(selected.length).toBe(2)
|
||||||
expect(selected.map((c) => c.id)).toContain("1")
|
expect(selected.map((c) => c.id)).toContain("1")
|
||||||
@@ -334,7 +319,7 @@ describe("SM-2 Algorithm", () => {
|
|||||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 1,
|
consecutiveCorrect: 1,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "2",
|
id: "2",
|
||||||
@@ -345,7 +330,7 @@ describe("SM-2 Algorithm", () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const selected = selectCardsForSession(cards, 10, now)
|
const selected = selectCardsForSession(cards, 10, now, false)
|
||||||
|
|
||||||
expect(selected.length).toBe(1)
|
expect(selected.length).toBe(1)
|
||||||
expect(selected[0].id).toBe("1")
|
expect(selected[0].id).toBe("1")
|
||||||
@@ -372,11 +357,11 @@ describe("SM-2 Algorithm", () => {
|
|||||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 1,
|
consecutiveCorrect: 1,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const selected = selectCardsForSession(cards, 10, now)
|
const selected = selectCardsForSession(cards, 10, now, false)
|
||||||
|
|
||||||
expect(selected[0].id).toBe("hard")
|
expect(selected[0].id).toBe("hard")
|
||||||
expect(selected[1].id).toBe("normal")
|
expect(selected[1].id).toBe("normal")
|
||||||
@@ -390,25 +375,25 @@ describe("SM-2 Algorithm", () => {
|
|||||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 1,
|
consecutiveCorrect: 1,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "2",
|
id: "2",
|
||||||
nextReviewDate: new Date("2025-01-12T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-12T10:00:00Z"),
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 1,
|
consecutiveCorrect: 1,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "3",
|
id: "3",
|
||||||
nextReviewDate: new Date("2025-01-13T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-13T10:00:00Z"),
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 1,
|
consecutiveCorrect: 1,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const selected = selectCardsForSession(cards, 10, now)
|
const selected = selectCardsForSession(cards, 10, now, false)
|
||||||
|
|
||||||
expect(selected[0].id).toBe("2") // Oldest
|
expect(selected[0].id).toBe("2") // Oldest
|
||||||
expect(selected[1].id).toBe("3")
|
expect(selected[1].id).toBe("3")
|
||||||
@@ -422,25 +407,25 @@ describe("SM-2 Algorithm", () => {
|
|||||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||||
incorrectCount: 1,
|
incorrectCount: 1,
|
||||||
consecutiveCorrect: 1,
|
consecutiveCorrect: 1,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "2",
|
id: "2",
|
||||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||||
incorrectCount: 3,
|
incorrectCount: 3,
|
||||||
consecutiveCorrect: 1,
|
consecutiveCorrect: 1,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "3",
|
id: "3",
|
||||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||||
incorrectCount: 2,
|
incorrectCount: 2,
|
||||||
consecutiveCorrect: 1,
|
consecutiveCorrect: 1,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const selected = selectCardsForSession(cards, 10, now)
|
const selected = selectCardsForSession(cards, 10, now, false)
|
||||||
|
|
||||||
expect(selected[0].id).toBe("2") // incorrectCount: 3
|
expect(selected[0].id).toBe("2") // incorrectCount: 3
|
||||||
expect(selected[1].id).toBe("3") // incorrectCount: 2
|
expect(selected[1].id).toBe("3") // incorrectCount: 2
|
||||||
@@ -454,25 +439,25 @@ describe("SM-2 Algorithm", () => {
|
|||||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 3,
|
consecutiveCorrect: 3,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "2",
|
id: "2",
|
||||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 1,
|
consecutiveCorrect: 1,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "3",
|
id: "3",
|
||||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 2,
|
consecutiveCorrect: 2,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const selected = selectCardsForSession(cards, 10, now)
|
const selected = selectCardsForSession(cards, 10, now, false)
|
||||||
|
|
||||||
expect(selected[0].id).toBe("2") // consecutiveCorrect: 1
|
expect(selected[0].id).toBe("2") // consecutiveCorrect: 1
|
||||||
expect(selected[1].id).toBe("3") // consecutiveCorrect: 2
|
expect(selected[1].id).toBe("3") // consecutiveCorrect: 2
|
||||||
@@ -486,18 +471,18 @@ describe("SM-2 Algorithm", () => {
|
|||||||
nextReviewDate: null, // New card
|
nextReviewDate: null, // New card
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 0,
|
consecutiveCorrect: 0,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "2",
|
id: "2",
|
||||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 1,
|
consecutiveCorrect: 1,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const selected = selectCardsForSession(cards, 10, now)
|
const selected = selectCardsForSession(cards, 10, now, false)
|
||||||
|
|
||||||
expect(selected.length).toBe(2)
|
expect(selected.length).toBe(2)
|
||||||
expect(selected[0].id).toBe("1") // New cards first
|
expect(selected[0].id).toBe("1") // New cards first
|
||||||
@@ -510,10 +495,10 @@ describe("SM-2 Algorithm", () => {
|
|||||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 1,
|
consecutiveCorrect: 1,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const selected = selectCardsForSession(cards, 5, now)
|
const selected = selectCardsForSession(cards, 5, now, false)
|
||||||
|
|
||||||
expect(selected.length).toBe(5)
|
expect(selected.length).toBe(5)
|
||||||
})
|
})
|
||||||
@@ -546,11 +531,11 @@ describe("SM-2 Algorithm", () => {
|
|||||||
nextReviewDate: new Date("2025-01-12T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-12T10:00:00Z"),
|
||||||
incorrectCount: 5,
|
incorrectCount: 5,
|
||||||
consecutiveCorrect: 1,
|
consecutiveCorrect: 1,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const selected = selectCardsForSession(cards, 10, now)
|
const selected = selectCardsForSession(cards, 10, now, false)
|
||||||
|
|
||||||
// Expected order:
|
// Expected order:
|
||||||
// 1. HARD difficulty has priority
|
// 1. HARD difficulty has priority
|
||||||
@@ -564,7 +549,7 @@ describe("SM-2 Algorithm", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("should handle empty card list", () => {
|
it("should handle empty card list", () => {
|
||||||
const selected = selectCardsForSession([], 10, now)
|
const selected = selectCardsForSession([], 10, now, false)
|
||||||
expect(selected.length).toBe(0)
|
expect(selected.length).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -586,7 +571,7 @@ describe("SM-2 Algorithm", () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const selected = selectCardsForSession(cards, 10, now)
|
const selected = selectCardsForSession(cards, 10, now, false)
|
||||||
expect(selected.length).toBe(0)
|
expect(selected.length).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -597,18 +582,18 @@ describe("SM-2 Algorithm", () => {
|
|||||||
nextReviewDate: new Date("2025-01-16T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-16T10:00:00Z"),
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 1,
|
consecutiveCorrect: 1,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "2",
|
id: "2",
|
||||||
nextReviewDate: new Date("2025-01-17T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-17T10:00:00Z"),
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 1,
|
consecutiveCorrect: 1,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const selected = selectCardsForSession(cards, 10, now)
|
const selected = selectCardsForSession(cards, 10, now, false)
|
||||||
expect(selected.length).toBe(0)
|
expect(selected.length).toBe(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
* Reference: https://www.supermemo.com/en/archives1990-2015/english/ol/sm2
|
* Reference: https://www.supermemo.com/en/archives1990-2015/english/ol/sm2
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Difficulty } from "@prisma/client"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Progress data for a single card
|
* Progress data for a single card
|
||||||
*/
|
*/
|
||||||
@@ -15,20 +17,17 @@ export interface CardProgress {
|
|||||||
interval: number // in days
|
interval: number // in days
|
||||||
consecutiveCorrect: number
|
consecutiveCorrect: number
|
||||||
incorrectCount: number
|
incorrectCount: number
|
||||||
lastReviewDate: Date | null
|
|
||||||
nextReviewDate: Date | null
|
nextReviewDate: Date | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initial values for a new card
|
* Initial values for a new card (without nextReviewDate as it's set on creation)
|
||||||
*/
|
*/
|
||||||
export const INITIAL_PROGRESS: CardProgress = {
|
export const INITIAL_PROGRESS = {
|
||||||
easeFactor: 2.5,
|
easeFactor: 2.5,
|
||||||
interval: 1,
|
interval: 1,
|
||||||
consecutiveCorrect: 0,
|
consecutiveCorrect: 0,
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
lastReviewDate: null,
|
|
||||||
nextReviewDate: null,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -122,13 +121,20 @@ export function calculateIncorrectAnswer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Difficulty enum matching the Prisma schema
|
* Re-export Difficulty enum from Prisma for convenience
|
||||||
*/
|
*/
|
||||||
export enum Difficulty {
|
export { Difficulty }
|
||||||
EASY = "EASY",
|
|
||||||
NORMAL = "NORMAL",
|
/**
|
||||||
HARD = "HARD",
|
* Shuffle array using Fisher-Yates algorithm
|
||||||
SUSPENDED = "SUSPENDED",
|
*/
|
||||||
|
function shuffleArray<T>(array: T[]): T[] {
|
||||||
|
const shuffled = [...array]
|
||||||
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1))
|
||||||
|
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
|
||||||
|
}
|
||||||
|
return shuffled
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -160,7 +166,8 @@ export interface SelectableCard {
|
|||||||
export function selectCardsForSession(
|
export function selectCardsForSession(
|
||||||
cards: SelectableCard[],
|
cards: SelectableCard[],
|
||||||
cardsPerSession: number,
|
cardsPerSession: number,
|
||||||
now: Date = new Date()
|
now: Date = new Date(),
|
||||||
|
shuffle: boolean = true
|
||||||
): SelectableCard[] {
|
): SelectableCard[] {
|
||||||
// Filter out suspended cards
|
// Filter out suspended cards
|
||||||
const activeCards = cards.filter(
|
const activeCards = cards.filter(
|
||||||
@@ -177,7 +184,7 @@ export function selectCardsForSession(
|
|||||||
// Priority by difficulty: HARD > NORMAL > EASY
|
// Priority by difficulty: HARD > NORMAL > EASY
|
||||||
const difficultyPriority = {
|
const difficultyPriority = {
|
||||||
[Difficulty.HARD]: 0,
|
[Difficulty.HARD]: 0,
|
||||||
[Difficulty.NORMAL]: 1,
|
[Difficulty.MEDIUM]: 1,
|
||||||
[Difficulty.EASY]: 2,
|
[Difficulty.EASY]: 2,
|
||||||
[Difficulty.SUSPENDED]: 3, // Should not appear due to filter
|
[Difficulty.SUSPENDED]: 3, // Should not appear due to filter
|
||||||
}
|
}
|
||||||
@@ -203,11 +210,20 @@ export function selectCardsForSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sort by consecutiveCorrect ASC (fewer correct = higher priority)
|
// Sort by consecutiveCorrect ASC (fewer correct = higher priority)
|
||||||
return a.consecutiveCorrect - b.consecutiveCorrect
|
if (a.consecutiveCorrect !== b.consecutiveCorrect) {
|
||||||
|
return a.consecutiveCorrect - b.consecutiveCorrect
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random tiebreaker for cards with equal priority
|
||||||
|
return Math.random() - 0.5
|
||||||
})
|
})
|
||||||
|
|
||||||
// Limit to cardsPerSession
|
// Limit to cardsPerSession
|
||||||
return sortedCards.slice(0, cardsPerSession)
|
const selectedCards = sortedCards.slice(0, cardsPerSession)
|
||||||
|
|
||||||
|
// Final shuffle: randomize the order of selected cards for presentation
|
||||||
|
// This prevents always showing hard/struggling cards first
|
||||||
|
return shuffle ? shuffleArray(selectedCards) : selectedCards
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user