learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs

feat: Spaced Repetition System (SRS) for card reviews

+1
Cargo.lock
··· 1606 1606 name = "malfestio-core" 1607 1607 version = "0.1.0" 1608 1608 dependencies = [ 1609 + "chrono", 1609 1610 "serde", 1610 1611 "serde_json", 1611 1612 "thiserror",
+1
crates/core/Cargo.toml
··· 4 4 edition = "2024" 5 5 6 6 [dependencies] 7 + chrono = { version = "0.4", features = ["serde"] } 7 8 serde = { version = "1.0.228", features = ["derive"] } 8 9 serde_json = "1.0.148" 9 10 thiserror = "2.0.17"
+2
crates/core/src/lib.rs
··· 1 1 pub mod at_uri; 2 2 pub mod error; 3 3 pub mod model; 4 + pub mod srs; 4 5 pub mod tid; 5 6 6 7 pub use error::{Error, Result}; 7 8 pub use model::{Card, Deck, Note}; 9 + pub use srs::{Grade, ReviewState, Sm2Config};
+217
crates/core/src/srs.rs
··· 1 + //! Spaced Repetition System (SM-2 Algorithm) 2 + //! 3 + //! Implements the SuperMemo 2 algorithm for scheduling card reviews. 4 + //! Parameters are designed to be user-configurable in the future. 5 + 6 + use chrono::{DateTime, Duration, Utc}; 7 + use serde::{Deserialize, Serialize}; 8 + 9 + /// Grade given by user during review (0-5 scale) 10 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 11 + #[serde(transparent)] 12 + pub struct Grade(pub u8); 13 + 14 + impl Grade { 15 + pub const AGAIN: Grade = Grade(0); 16 + pub const HARD: Grade = Grade(1); 17 + pub const GOOD: Grade = Grade(3); 18 + pub const EASY: Grade = Grade(4); 19 + pub const PERFECT: Grade = Grade(5); 20 + 21 + pub fn new(value: u8) -> Option<Self> { 22 + if value <= 5 { Some(Grade(value)) } else { None } 23 + } 24 + 25 + pub fn is_passing(&self) -> bool { 26 + self.0 >= 3 27 + } 28 + } 29 + 30 + /// Default SM-2 parameters (user-configurable in future) 31 + #[derive(Debug, Clone, Serialize, Deserialize)] 32 + pub struct Sm2Config { 33 + /// Initial ease factor for new cards 34 + pub initial_ease: f32, 35 + /// Minimum ease factor (prevents cards from becoming too hard) 36 + pub min_ease: f32, 37 + /// First interval in days after initial correct answer 38 + pub first_interval: i32, 39 + /// Second interval in days 40 + pub second_interval: i32, 41 + } 42 + 43 + impl Default for Sm2Config { 44 + fn default() -> Self { 45 + Self { initial_ease: 2.5, min_ease: 1.3, first_interval: 1, second_interval: 6 } 46 + } 47 + } 48 + 49 + /// Current review state for a card 50 + #[derive(Debug, Clone, Serialize, Deserialize)] 51 + pub struct ReviewState { 52 + /// Ease factor (multiplier for interval) 53 + pub ease_factor: f32, 54 + /// Current interval in days 55 + pub interval_days: i32, 56 + /// Number of consecutive correct reviews 57 + pub repetitions: i32, 58 + /// When the card is due 59 + pub due_at: DateTime<Utc>, 60 + } 61 + 62 + impl Default for ReviewState { 63 + fn default() -> Self { 64 + Self { ease_factor: 2.5, interval_days: 0, repetitions: 0, due_at: Utc::now() } 65 + } 66 + } 67 + 68 + impl ReviewState { 69 + /// Create a new review state for a fresh card 70 + pub fn new() -> Self { 71 + Self::default() 72 + } 73 + 74 + /// Calculate next review state based on grade using SM-2 algorithm 75 + pub fn schedule(&self, grade: Grade, config: &Sm2Config) -> Self { 76 + let q = grade.0 as f32; 77 + 78 + // TODO: move to separate fn 79 + // EF' = EF + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02)) 80 + let new_ease = self.ease_factor + (0.1 - (5.0 - q) * (0.08 + (5.0 - q) * 0.02)); 81 + let new_ease = new_ease.max(config.min_ease); 82 + 83 + if grade.is_passing() { 84 + let (new_interval, new_reps) = match self.repetitions { 85 + 0 => (config.first_interval, 1), 86 + 1 => (config.second_interval, 2), 87 + _ => { 88 + let interval = (self.interval_days as f32 * new_ease).round() as i32; 89 + (interval.max(1), self.repetitions + 1) 90 + } 91 + }; 92 + 93 + Self { 94 + ease_factor: new_ease, 95 + interval_days: new_interval, 96 + repetitions: new_reps, 97 + due_at: Utc::now() + Duration::days(new_interval as i64), 98 + } 99 + } else { 100 + Self { ease_factor: new_ease, interval_days: 0, repetitions: 0, due_at: Utc::now() + Duration::minutes(10) } 101 + } 102 + } 103 + } 104 + 105 + #[cfg(test)] 106 + mod tests { 107 + use super::*; 108 + 109 + #[test] 110 + fn test_grade_validation() { 111 + assert!(Grade::new(0).is_some()); 112 + assert!(Grade::new(5).is_some()); 113 + assert!(Grade::new(6).is_none()); 114 + } 115 + 116 + #[test] 117 + fn test_grade_passing() { 118 + assert!(!Grade::AGAIN.is_passing()); 119 + assert!(!Grade::HARD.is_passing()); 120 + assert!(Grade::GOOD.is_passing()); 121 + assert!(Grade::EASY.is_passing()); 122 + assert!(Grade::PERFECT.is_passing()); 123 + } 124 + 125 + #[test] 126 + fn test_new_card_first_review_correct() { 127 + let config = Sm2Config::default(); 128 + let state = ReviewState::new(); 129 + 130 + let next = state.schedule(Grade::GOOD, &config); 131 + 132 + assert_eq!(next.interval_days, 1); 133 + assert_eq!(next.repetitions, 1); 134 + assert!(next.ease_factor >= 2.3 && next.ease_factor <= 2.7); 135 + } 136 + 137 + #[test] 138 + fn test_new_card_first_review_incorrect() { 139 + let config = Sm2Config::default(); 140 + let state = ReviewState::new(); 141 + 142 + let next = state.schedule(Grade::AGAIN, &config); 143 + 144 + assert_eq!(next.interval_days, 0); 145 + assert_eq!(next.repetitions, 0); 146 + let diff = next.due_at - Utc::now(); 147 + assert!(diff.num_minutes() <= 15); 148 + } 149 + 150 + #[test] 151 + fn test_second_review_correct() { 152 + let config = Sm2Config::default(); 153 + let state = ReviewState { ease_factor: 2.5, interval_days: 1, repetitions: 1, due_at: Utc::now() }; 154 + let next = state.schedule(Grade::GOOD, &config); 155 + 156 + assert_eq!(next.interval_days, 6); 157 + assert_eq!(next.repetitions, 2); 158 + } 159 + 160 + #[test] 161 + fn test_mature_card_interval_grows() { 162 + let config = Sm2Config::default(); 163 + let state = ReviewState { ease_factor: 2.5, interval_days: 10, repetitions: 5, due_at: Utc::now() }; 164 + let next = state.schedule(Grade::GOOD, &config); 165 + 166 + assert!(next.interval_days >= 20); 167 + assert_eq!(next.repetitions, 6); 168 + } 169 + 170 + #[test] 171 + fn test_ease_factor_minimum() { 172 + let config = Sm2Config::default(); 173 + let state = ReviewState { ease_factor: 1.4, interval_days: 5, repetitions: 3, due_at: Utc::now() }; 174 + let next = state.schedule(Grade::GOOD, &config); 175 + 176 + assert!(next.ease_factor >= config.min_ease); 177 + } 178 + 179 + #[test] 180 + fn test_easy_increases_ease() { 181 + let config = Sm2Config::default(); 182 + let state = ReviewState::new(); 183 + 184 + let next = state.schedule(Grade::PERFECT, &config); 185 + 186 + assert!(next.ease_factor > 2.5); 187 + } 188 + 189 + #[test] 190 + fn test_hard_decreases_ease() { 191 + let config = Sm2Config::default(); 192 + let state = ReviewState { ease_factor: 2.5, interval_days: 10, repetitions: 5, due_at: Utc::now() }; 193 + let next = state.schedule(Grade::HARD, &config); 194 + 195 + assert!(next.ease_factor < 2.5); 196 + } 197 + 198 + #[test] 199 + fn test_30_day_simulation() { 200 + let config = Sm2Config::default(); 201 + let mut state = ReviewState::new(); 202 + 203 + state = state.schedule(Grade::GOOD, &config); 204 + assert_eq!(state.interval_days, 1); 205 + 206 + state = state.schedule(Grade::GOOD, &config); 207 + assert_eq!(state.interval_days, 6); 208 + 209 + state = state.schedule(Grade::GOOD, &config); 210 + assert!(state.interval_days >= 12 && state.interval_days <= 20); 211 + 212 + state = state.schedule(Grade::GOOD, &config); 213 + assert!(state.interval_days >= 20); 214 + assert_eq!(state.repetitions, 4); 215 + assert!(state.ease_factor >= config.min_ease); 216 + } 217 + }
+1
crates/server/src/api/mod.rs
··· 4 4 pub mod importer; 5 5 pub mod note; 6 6 pub mod oauth; 7 + pub mod review;
+234
crates/server/src/api/review.rs
··· 1 + use crate::middleware::auth::UserContext; 2 + use crate::repository::review::ReviewRepoError; 3 + use crate::state::SharedState; 4 + 5 + use axum::{ 6 + Json, 7 + extract::{Extension, Query, State}, 8 + http::StatusCode, 9 + response::IntoResponse, 10 + }; 11 + use malfestio_core::srs::Grade; 12 + use serde::{Deserialize, Serialize}; 13 + use serde_json::json; 14 + 15 + #[derive(Deserialize)] 16 + pub struct DueCardsQuery { 17 + deck_id: Option<String>, 18 + #[serde(default = "default_limit")] 19 + limit: i64, 20 + } 21 + 22 + fn default_limit() -> i64 { 23 + 20 24 + } 25 + 26 + #[derive(Deserialize)] 27 + pub struct SubmitReviewRequest { 28 + card_id: String, 29 + grade: u8, 30 + } 31 + 32 + #[derive(Serialize)] 33 + pub struct SubmitReviewResponse { 34 + ease_factor: f32, 35 + interval_days: i32, 36 + repetitions: i32, 37 + due_at: String, 38 + } 39 + 40 + /// GET /api/review/due - Get cards due for review 41 + pub async fn get_due_cards( 42 + State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, Query(query): Query<DueCardsQuery>, 43 + ) -> impl IntoResponse { 44 + let user = match ctx { 45 + Some(Extension(user)) => user, 46 + None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), 47 + }; 48 + 49 + let result = state 50 + .review_repo 51 + .get_due_cards(&user.did, query.deck_id.as_deref(), query.limit) 52 + .await; 53 + 54 + match result { 55 + Ok(cards) => Json(cards).into_response(), 56 + Err(ReviewRepoError::InvalidArgument(msg)) => { 57 + (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response() 58 + } 59 + Err(e) => { 60 + tracing::error!("Failed to get due cards: {:?}", e); 61 + ( 62 + StatusCode::INTERNAL_SERVER_ERROR, 63 + Json(json!({"error": "Failed to get due cards"})), 64 + ) 65 + .into_response() 66 + } 67 + } 68 + } 69 + 70 + /// POST /api/review/submit - Submit a review grade 71 + pub async fn submit_review( 72 + State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, Json(payload): Json<SubmitReviewRequest>, 73 + ) -> impl IntoResponse { 74 + let user = match ctx { 75 + Some(Extension(user)) => user, 76 + None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), 77 + }; 78 + 79 + let grade = match Grade::new(payload.grade) { 80 + Some(g) => g, 81 + None => return (StatusCode::BAD_REQUEST, Json(json!({"error": "Grade must be 0-5"}))).into_response(), 82 + }; 83 + 84 + let result = state 85 + .review_repo 86 + .submit_review(&user.did, &payload.card_id, grade) 87 + .await; 88 + 89 + match result { 90 + Ok(new_state) => Json(SubmitReviewResponse { 91 + ease_factor: new_state.ease_factor, 92 + interval_days: new_state.interval_days, 93 + repetitions: new_state.repetitions, 94 + due_at: new_state.due_at.to_rfc3339(), 95 + }) 96 + .into_response(), 97 + Err(ReviewRepoError::InvalidArgument(msg)) => { 98 + (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response() 99 + } 100 + Err(e) => { 101 + tracing::error!("Failed to submit review: {:?}", e); 102 + ( 103 + StatusCode::INTERNAL_SERVER_ERROR, 104 + Json(json!({"error": "Failed to submit review"})), 105 + ) 106 + .into_response() 107 + } 108 + } 109 + } 110 + 111 + /// GET /api/review/stats - Get user study statistics 112 + pub async fn get_stats(State(state): State<SharedState>, ctx: Option<Extension<UserContext>>) -> impl IntoResponse { 113 + let user = match ctx { 114 + Some(Extension(user)) => user, 115 + None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), 116 + }; 117 + 118 + let result = state.review_repo.get_stats(&user.did).await; 119 + 120 + match result { 121 + Ok(stats) => Json(stats).into_response(), 122 + Err(e) => { 123 + tracing::error!("Failed to get stats: {:?}", e); 124 + ( 125 + StatusCode::INTERNAL_SERVER_ERROR, 126 + Json(json!({"error": "Failed to get stats"})), 127 + ) 128 + .into_response() 129 + } 130 + } 131 + } 132 + 133 + #[cfg(test)] 134 + mod tests { 135 + use super::*; 136 + use crate::repository::card::mock::MockCardRepository; 137 + use crate::repository::note::mock::MockNoteRepository; 138 + use crate::repository::oauth::mock::MockOAuthRepository; 139 + use crate::repository::review::mock::MockReviewRepository; 140 + use crate::repository::review::{ReviewCard, ReviewRepository}; 141 + use crate::state::AppState; 142 + use chrono::Utc; 143 + use std::sync::Arc; 144 + 145 + fn create_test_state_with_review(review_repo: Arc<dyn ReviewRepository>) -> SharedState { 146 + let pool = crate::db::create_mock_pool(); 147 + let card_repo = Arc::new(MockCardRepository::new()) as Arc<dyn crate::repository::card::CardRepository>; 148 + let note_repo = Arc::new(MockNoteRepository::new()) as Arc<dyn crate::repository::note::NoteRepository>; 149 + let oauth_repo = Arc::new(MockOAuthRepository::new()) as Arc<dyn crate::repository::oauth::OAuthRepository>; 150 + 151 + Arc::new(AppState { pool, card_repo, note_repo, oauth_repo, review_repo }) 152 + } 153 + 154 + #[tokio::test] 155 + async fn test_get_due_cards_unauthorized() { 156 + let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn ReviewRepository>; 157 + let state = create_test_state_with_review(review_repo); 158 + 159 + let response = get_due_cards(State(state), None, Query(DueCardsQuery { deck_id: None, limit: 20 })) 160 + .await 161 + .into_response(); 162 + 163 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 164 + } 165 + 166 + #[tokio::test] 167 + async fn test_get_due_cards_success() { 168 + let cards = vec![ReviewCard { 169 + review_id: "review-1".to_string(), 170 + card_id: "card-1".to_string(), 171 + deck_id: "deck-1".to_string(), 172 + deck_title: "Test Deck".to_string(), 173 + front: "What is 2+2?".to_string(), 174 + back: "4".to_string(), 175 + media_url: None, 176 + hints: vec![], 177 + due_at: Utc::now(), 178 + }]; 179 + let review_repo = Arc::new(MockReviewRepository::with_cards(cards)) as Arc<dyn ReviewRepository>; 180 + let state = create_test_state_with_review(review_repo); 181 + 182 + let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() }; 183 + let response = get_due_cards( 184 + State(state), 185 + Some(Extension(user)), 186 + Query(DueCardsQuery { deck_id: None, limit: 20 }), 187 + ) 188 + .await 189 + .into_response(); 190 + 191 + assert_eq!(response.status(), StatusCode::OK); 192 + } 193 + 194 + #[tokio::test] 195 + async fn test_submit_review_success() { 196 + let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn ReviewRepository>; 197 + let state = create_test_state_with_review(review_repo); 198 + 199 + let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() }; 200 + let payload = SubmitReviewRequest { card_id: "card-1".to_string(), grade: 3 }; 201 + 202 + let response = submit_review(State(state), Some(Extension(user)), Json(payload)) 203 + .await 204 + .into_response(); 205 + 206 + assert_eq!(response.status(), StatusCode::OK); 207 + } 208 + 209 + #[tokio::test] 210 + async fn test_submit_review_invalid_grade() { 211 + let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn ReviewRepository>; 212 + let state = create_test_state_with_review(review_repo); 213 + 214 + let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() }; 215 + let payload = SubmitReviewRequest { card_id: "card-1".to_string(), grade: 10 }; 216 + 217 + let response = submit_review(State(state), Some(Extension(user)), Json(payload)) 218 + .await 219 + .into_response(); 220 + 221 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 222 + } 223 + 224 + #[tokio::test] 225 + async fn test_get_stats_success() { 226 + let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn ReviewRepository>; 227 + let state = create_test_state_with_review(review_repo); 228 + 229 + let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() }; 230 + let response = get_stats(State(state), Some(Extension(user))).await.into_response(); 231 + 232 + assert_eq!(response.status(), StatusCode::OK); 233 + } 234 + }
+3
crates/server/src/lib.rs
··· 50 50 .route("/decks/{id}/publish", post(api::deck::publish_deck)) 51 51 .route("/notes", post(api::note::create_note)) 52 52 .route("/cards", post(api::card::create_card)) 53 + .route("/review/due", get(api::review::get_due_cards)) 54 + .route("/review/submit", post(api::review::submit_review)) 55 + .route("/review/stats", get(api::review::get_stats)) 53 56 .layer(axum_middleware::from_fn(middleware::auth::auth_middleware)); 54 57 55 58 let optional_auth_routes = Router::new()
+1
crates/server/src/repository/mod.rs
··· 1 1 pub mod card; 2 2 pub mod note; 3 3 pub mod oauth; 4 + pub mod review;
+378
crates/server/src/repository/review.rs
··· 1 + use async_trait::async_trait; 2 + use chrono::{DateTime, Utc}; 3 + use malfestio_core::srs::{Grade, ReviewState, Sm2Config}; 4 + use serde::{Deserialize, Serialize}; 5 + 6 + #[derive(Debug)] 7 + pub enum ReviewRepoError { 8 + DatabaseError(String), 9 + NotFound(String), 10 + InvalidArgument(String), 11 + } 12 + 13 + /// Card with review state for study sessions 14 + #[derive(Debug, Clone, Serialize, Deserialize)] 15 + pub struct ReviewCard { 16 + pub review_id: String, 17 + pub card_id: String, 18 + pub deck_id: String, 19 + pub deck_title: String, 20 + pub front: String, 21 + pub back: String, 22 + pub media_url: Option<String>, 23 + pub hints: Vec<String>, 24 + pub due_at: DateTime<Utc>, 25 + } 26 + 27 + /// User study statistics 28 + #[derive(Debug, Clone, Default, Serialize, Deserialize)] 29 + pub struct StudyStats { 30 + pub due_count: i64, 31 + pub current_streak: i32, 32 + pub longest_streak: i32, 33 + pub reviewed_today: i64, 34 + pub total_reviews: i64, 35 + } 36 + 37 + #[async_trait] 38 + pub trait ReviewRepository: Send + Sync { 39 + /// Get cards due for review, optionally filtered by deck 40 + async fn get_due_cards( 41 + &self, user_did: &str, deck_id: Option<&str>, limit: i64, 42 + ) -> Result<Vec<ReviewCard>, ReviewRepoError>; 43 + 44 + /// Submit a review grade for a card 45 + async fn submit_review(&self, user_did: &str, card_id: &str, grade: Grade) -> Result<ReviewState, ReviewRepoError>; 46 + 47 + /// Get study statistics for a user 48 + async fn get_stats(&self, user_did: &str) -> Result<StudyStats, ReviewRepoError>; 49 + } 50 + 51 + pub struct DbReviewRepository { 52 + pool: crate::db::DbPool, 53 + config: Sm2Config, 54 + } 55 + 56 + impl DbReviewRepository { 57 + pub fn new(pool: crate::db::DbPool) -> Self { 58 + Self { pool, config: Sm2Config::default() } 59 + } 60 + } 61 + 62 + #[async_trait] 63 + impl ReviewRepository for DbReviewRepository { 64 + async fn get_due_cards( 65 + &self, user_did: &str, deck_id: Option<&str>, limit: i64, 66 + ) -> Result<Vec<ReviewCard>, ReviewRepoError> { 67 + let client = self 68 + .pool 69 + .get() 70 + .await 71 + .map_err(|e| ReviewRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 72 + 73 + let now = Utc::now(); 74 + 75 + let rows = if let Some(deck_id) = deck_id { 76 + let deck_uuid = uuid::Uuid::parse_str(deck_id) 77 + .map_err(|_| ReviewRepoError::InvalidArgument("Invalid deck ID".to_string()))?; 78 + 79 + client 80 + .query( 81 + r#" 82 + SELECT 83 + cr.id as review_id, 84 + c.id as card_id, 85 + c.deck_id, 86 + d.title as deck_title, 87 + c.front, 88 + c.back, 89 + c.media_url, 90 + cr.due_at 91 + FROM cards c 92 + JOIN decks d ON c.deck_id = d.id 93 + LEFT JOIN card_reviews cr ON c.id = cr.card_id AND cr.user_did = $1 94 + WHERE c.deck_id = $2 95 + AND (cr.due_at IS NULL OR cr.due_at <= $3) 96 + ORDER BY COALESCE(cr.due_at, '1970-01-01'::timestamptz) ASC 97 + LIMIT $4 98 + "#, 99 + &[&user_did, &deck_uuid, &now, &limit], 100 + ) 101 + .await 102 + } else { 103 + client 104 + .query( 105 + r#" 106 + SELECT 107 + cr.id as review_id, 108 + c.id as card_id, 109 + c.deck_id, 110 + d.title as deck_title, 111 + c.front, 112 + c.back, 113 + c.media_url, 114 + cr.due_at 115 + FROM cards c 116 + JOIN decks d ON c.deck_id = d.id 117 + LEFT JOIN card_reviews cr ON c.id = cr.card_id AND cr.user_did = $1 118 + WHERE d.owner_did = $1 119 + AND (cr.due_at IS NULL OR cr.due_at <= $2) 120 + ORDER BY COALESCE(cr.due_at, '1970-01-01'::timestamptz) ASC 121 + LIMIT $3 122 + "#, 123 + &[&user_did, &now, &limit], 124 + ) 125 + .await 126 + }; 127 + 128 + let rows = rows.map_err(|e| ReviewRepoError::DatabaseError(format!("Failed to query cards: {}", e)))?; 129 + 130 + let mut cards = Vec::new(); 131 + for row in rows { 132 + let review_id: Option<uuid::Uuid> = row.get("review_id"); 133 + let card_id: uuid::Uuid = row.get("card_id"); 134 + let deck_id: uuid::Uuid = row.get("deck_id"); 135 + let due_at: Option<DateTime<Utc>> = row.get("due_at"); 136 + 137 + cards.push(ReviewCard { 138 + review_id: review_id.map(|id| id.to_string()).unwrap_or_default(), 139 + card_id: card_id.to_string(), 140 + deck_id: deck_id.to_string(), 141 + deck_title: row.get("deck_title"), 142 + front: row.get("front"), 143 + back: row.get("back"), 144 + media_url: row.get("media_url"), 145 + // TODO: Load hints when stored in DB 146 + hints: vec![], 147 + due_at: due_at.unwrap_or_else(Utc::now), 148 + }); 149 + } 150 + 151 + Ok(cards) 152 + } 153 + 154 + async fn submit_review(&self, user_did: &str, card_id: &str, grade: Grade) -> Result<ReviewState, ReviewRepoError> { 155 + let client = self 156 + .pool 157 + .get() 158 + .await 159 + .map_err(|e| ReviewRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 160 + 161 + let card_uuid = uuid::Uuid::parse_str(card_id) 162 + .map_err(|_| ReviewRepoError::InvalidArgument("Invalid card ID".to_string()))?; 163 + 164 + let existing = client 165 + .query_opt( 166 + "SELECT id, ease_factor, interval_days, repetitions, due_at FROM card_reviews WHERE card_id = $1 AND user_did = $2", 167 + &[&card_uuid, &user_did], 168 + ) 169 + .await 170 + .map_err(|e| ReviewRepoError::DatabaseError(format!("Failed to query review: {}", e)))?; 171 + 172 + let current_state = existing 173 + .map(|row| ReviewState { 174 + ease_factor: row.get::<_, f32>("ease_factor"), 175 + interval_days: row.get::<_, i32>("interval_days"), 176 + repetitions: row.get::<_, i32>("repetitions"), 177 + due_at: row.get("due_at"), 178 + }) 179 + .unwrap_or_default(); 180 + 181 + let new_state = current_state.schedule(grade, &self.config); 182 + let now = Utc::now(); 183 + 184 + client 185 + .execute( 186 + r#" 187 + INSERT INTO card_reviews (id, card_id, user_did, ease_factor, interval_days, repetitions, due_at, last_reviewed_at, total_reviews) 188 + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 1) 189 + ON CONFLICT (card_id, user_did) DO UPDATE SET 190 + ease_factor = $4, 191 + interval_days = $5, 192 + repetitions = $6, 193 + due_at = $7, 194 + last_reviewed_at = $8, 195 + total_reviews = card_reviews.total_reviews + 1 196 + "#, 197 + &[ 198 + &uuid::Uuid::new_v4(), 199 + &card_uuid, 200 + &user_did, 201 + &new_state.ease_factor, 202 + &new_state.interval_days, 203 + &new_state.repetitions, 204 + &new_state.due_at, 205 + &now, 206 + ], 207 + ) 208 + .await 209 + .map_err(|e| ReviewRepoError::DatabaseError(format!("Failed to update review: {}", e)))?; 210 + 211 + let today = now.date_naive(); 212 + client 213 + .execute( 214 + r#" 215 + INSERT INTO user_study_stats (id, user_did, current_streak, longest_streak, last_study_date, total_cards_reviewed) 216 + VALUES ($1, $2, 1, 1, $3, 1) 217 + ON CONFLICT (user_did) DO UPDATE SET 218 + current_streak = CASE 219 + WHEN user_study_stats.last_study_date = $3 THEN user_study_stats.current_streak 220 + WHEN user_study_stats.last_study_date = $3 - INTERVAL '1 day' THEN user_study_stats.current_streak + 1 221 + ELSE 1 222 + END, 223 + longest_streak = GREATEST(user_study_stats.longest_streak, 224 + CASE 225 + WHEN user_study_stats.last_study_date = $3 THEN user_study_stats.current_streak 226 + WHEN user_study_stats.last_study_date = $3 - INTERVAL '1 day' THEN user_study_stats.current_streak + 1 227 + ELSE 1 228 + END 229 + ), 230 + last_study_date = $3, 231 + total_cards_reviewed = user_study_stats.total_cards_reviewed + 1 232 + "#, 233 + &[&uuid::Uuid::new_v4(), &user_did, &today], 234 + ) 235 + .await 236 + .map_err(|e| ReviewRepoError::DatabaseError(format!("Failed to update stats: {}", e)))?; 237 + 238 + Ok(new_state) 239 + } 240 + 241 + async fn get_stats(&self, user_did: &str) -> Result<StudyStats, ReviewRepoError> { 242 + let client = self 243 + .pool 244 + .get() 245 + .await 246 + .map_err(|e| ReviewRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 247 + 248 + let now = Utc::now(); 249 + let today = now.date_naive(); 250 + 251 + let due_row = client 252 + .query_one( 253 + r#" 254 + SELECT COUNT(*) as due_count FROM cards c 255 + JOIN decks d ON c.deck_id = d.id 256 + LEFT JOIN card_reviews cr ON c.id = cr.card_id AND cr.user_did = $1 257 + WHERE d.owner_did = $1 258 + AND (cr.due_at IS NULL OR cr.due_at <= $2) 259 + "#, 260 + &[&user_did, &now], 261 + ) 262 + .await 263 + .map_err(|e| ReviewRepoError::DatabaseError(format!("Failed to count due cards: {}", e)))?; 264 + 265 + let due_count: i64 = due_row.get("due_count"); 266 + 267 + let reviewed_row = client 268 + .query_one( 269 + r#" 270 + SELECT COUNT(*) as reviewed_count FROM card_reviews 271 + WHERE user_did = $1 AND DATE(last_reviewed_at) = $2 272 + "#, 273 + &[&user_did, &today], 274 + ) 275 + .await 276 + .map_err(|e| ReviewRepoError::DatabaseError(format!("Failed to count reviews: {}", e)))?; 277 + 278 + let reviewed_today: i64 = reviewed_row.get("reviewed_count"); 279 + 280 + let stats_row = client 281 + .query_opt( 282 + "SELECT current_streak, longest_streak, total_cards_reviewed FROM user_study_stats WHERE user_did = $1", 283 + &[&user_did], 284 + ) 285 + .await 286 + .map_err(|e| ReviewRepoError::DatabaseError(format!("Failed to get stats: {}", e)))?; 287 + 288 + let (current_streak, longest_streak, total_reviews) = stats_row 289 + .map(|row| { 290 + ( 291 + row.get::<_, i32>("current_streak"), 292 + row.get::<_, i32>("longest_streak"), 293 + row.get::<_, i32>("total_cards_reviewed") as i64, 294 + ) 295 + }) 296 + .unwrap_or((0, 0, 0)); 297 + 298 + Ok(StudyStats { due_count, current_streak, longest_streak, reviewed_today, total_reviews }) 299 + } 300 + } 301 + 302 + #[cfg(test)] 303 + pub mod mock { 304 + use super::*; 305 + use std::sync::{Arc, Mutex}; 306 + 307 + #[derive(Clone)] 308 + pub struct MockReviewRepository { 309 + pub cards: Arc<Mutex<Vec<ReviewCard>>>, 310 + pub should_fail: Arc<Mutex<bool>>, 311 + } 312 + 313 + impl MockReviewRepository { 314 + pub fn new() -> Self { 315 + Self { cards: Arc::new(Mutex::new(Vec::new())), should_fail: Arc::new(Mutex::new(false)) } 316 + } 317 + 318 + pub fn with_cards(cards: Vec<ReviewCard>) -> Self { 319 + Self { cards: Arc::new(Mutex::new(cards)), should_fail: Arc::new(Mutex::new(false)) } 320 + } 321 + 322 + #[allow(dead_code)] 323 + pub fn set_should_fail(&self, should_fail: bool) { 324 + *self.should_fail.lock().unwrap() = should_fail; 325 + } 326 + } 327 + 328 + impl Default for MockReviewRepository { 329 + fn default() -> Self { 330 + Self::new() 331 + } 332 + } 333 + 334 + #[async_trait] 335 + impl ReviewRepository for MockReviewRepository { 336 + async fn get_due_cards( 337 + &self, _user_did: &str, deck_id: Option<&str>, limit: i64, 338 + ) -> Result<Vec<ReviewCard>, ReviewRepoError> { 339 + if *self.should_fail.lock().unwrap() { 340 + return Err(ReviewRepoError::DatabaseError("Mock failure".to_string())); 341 + } 342 + 343 + let cards = self.cards.lock().unwrap(); 344 + let filtered: Vec<_> = cards 345 + .iter() 346 + .filter(|c| deck_id.is_none_or(|id| c.deck_id == id)) 347 + .take(limit as usize) 348 + .cloned() 349 + .collect(); 350 + Ok(filtered) 351 + } 352 + 353 + async fn submit_review( 354 + &self, _user_did: &str, _card_id: &str, grade: Grade, 355 + ) -> Result<ReviewState, ReviewRepoError> { 356 + if *self.should_fail.lock().unwrap() { 357 + return Err(ReviewRepoError::DatabaseError("Mock failure".to_string())); 358 + } 359 + 360 + let state = ReviewState::default(); 361 + Ok(state.schedule(grade, &Sm2Config::default())) 362 + } 363 + 364 + async fn get_stats(&self, _user_did: &str) -> Result<StudyStats, ReviewRepoError> { 365 + if *self.should_fail.lock().unwrap() { 366 + return Err(ReviewRepoError::DatabaseError("Mock failure".to_string())); 367 + } 368 + 369 + Ok(StudyStats { 370 + due_count: self.cards.lock().unwrap().len() as i64, 371 + current_streak: 5, 372 + longest_streak: 10, 373 + reviewed_today: 3, 374 + total_reviews: 100, 375 + }) 376 + } 377 + } 378 + }
+7 -2
crates/server/src/state.rs
··· 2 2 use crate::repository::card::{CardRepository, DbCardRepository}; 3 3 use crate::repository::note::{DbNoteRepository, NoteRepository}; 4 4 use crate::repository::oauth::{DbOAuthRepository, OAuthRepository}; 5 + use crate::repository::review::{DbReviewRepository, ReviewRepository}; 5 6 use std::sync::Arc; 6 7 7 8 pub type SharedState = Arc<AppState>; ··· 11 12 pub card_repo: Arc<dyn CardRepository>, 12 13 pub note_repo: Arc<dyn NoteRepository>, 13 14 pub oauth_repo: Arc<dyn OAuthRepository>, 15 + pub review_repo: Arc<dyn ReviewRepository>, 14 16 } 15 17 16 18 impl AppState { ··· 18 20 let card_repo = Arc::new(DbCardRepository::new(pool.clone())) as Arc<dyn CardRepository>; 19 21 let note_repo = Arc::new(DbNoteRepository::new(pool.clone())) as Arc<dyn NoteRepository>; 20 22 let oauth_repo = Arc::new(DbOAuthRepository::new(pool.clone())) as Arc<dyn OAuthRepository>; 23 + let review_repo = Arc::new(DbReviewRepository::new(pool.clone())) as Arc<dyn ReviewRepository>; 21 24 22 - Arc::new(Self { pool, card_repo, note_repo, oauth_repo }) 25 + Arc::new(Self { pool, card_repo, note_repo, oauth_repo, review_repo }) 23 26 } 24 27 25 28 #[cfg(test)] ··· 27 30 pool: DbPool, card_repo: Arc<dyn CardRepository>, note_repo: Arc<dyn NoteRepository>, 28 31 oauth_repo: Arc<dyn OAuthRepository>, 29 32 ) -> SharedState { 30 - Arc::new(Self { pool, card_repo, note_repo, oauth_repo }) 33 + let review_repo = 34 + Arc::new(crate::repository::review::mock::MockReviewRepository::new()) as Arc<dyn ReviewRepository>; 35 + Arc::new(Self { pool, card_repo, note_repo, oauth_repo, review_repo }) 31 36 } 32 37 }
+6 -20
docs/todo.md
··· 53 53 - **(Done) Milestone E**: Internal component library/UI Foundation + Animations. 54 54 - **(Done) Milestone F**: Content Authoring (Notes + Cards + Deck Builder). 55 55 56 - ### Milestone G - Study Engine (SRS) + Daily Review UX 57 - 58 - #### Deliverables 59 - 60 - - SRS scheduler (SM-2 baseline) 61 - - grade 0–5, EF, interval, repetition count 62 - - Review queue generation rules 63 - - Study session UI: 64 - - keyboard-first review loop 65 - - quick edit card during review 66 - - Progress views (private): 67 - - due count, retention proxy, streaks 68 - 69 - #### Acceptance 70 - 71 - - 30-day simulated study test produces stable, believable intervals. 72 - 73 - #### Notes 74 - 75 - - SM-2 reference behavior is well documented; start there and iterate. 56 + - **(Done) Milestone G**: Study Engine (SRS) + Daily Review UX. 57 + - SM-2 spaced repetition scheduler. 58 + - Review repository with due card queries and stats tracking. 59 + - API endpoints: `/review/due`, `/review/submit`, `/review/stats`. 60 + - StudySession component with keyboard-first review. 61 + - ReviewStats component for progress display. 76 62 77 63 ### Milestone H - Social Layer v1 (Follow, Feed, Fork, Comments) 78 64
+107
docs/user-flows.md
··· 1 + # User Flows 2 + 3 + User experience pathways for Malfestio. 4 + 5 + ## Authentication 6 + 7 + ### Login 8 + 9 + 1. Navigate to `/login` 10 + 2. Enter Bluesky handle and app password 11 + 3. Submit → redirected to Library 12 + 13 + ### Logout 14 + 15 + 1. Click avatar in header → "Logout" 16 + 2. → redirected to Landing page 17 + 18 + ## Deck Management 19 + 20 + ### Create Deck 21 + 22 + 1. Library (`/`) → "Create Deck" 23 + 2. Fill: title, description, tags 24 + 3. Set visibility (Private/Unlisted/Public/SharedWith) 25 + 4. Add cards (front/back, optional hints, card type) 26 + 5. Submit → deck created, redirected to Library 27 + 28 + ### View Deck 29 + 30 + 1. Library → click deck card 31 + 2. View title, description, tags, card list 32 + 3. Options: Edit, Study, Back to Library 33 + 34 + ### Study Deck 35 + 36 + 1. Deck View → "Study Deck" 37 + 2. Study session with keyboard controls 38 + 3. Grade cards (1-5), view progress 39 + 4. Session complete → return to deck 40 + 41 + ## Note Management 42 + 43 + ### Create Note 44 + 45 + 1. Header → "Notes" → "New Note" 46 + 2. Fill: title, body (markdown), tags 47 + 3. Add wikilinks with `[[Note Title]]` 48 + 4. Set visibility 49 + 5. Submit → note created 50 + 51 + ### View Notes 52 + 53 + 1. Header → "Notes" 54 + 2. Browse notes with backlink navigation 55 + 56 + ## Content Import 57 + 58 + ### Import Article 59 + 60 + 1. Header → "Import" 61 + 2. Enter article URL 62 + 3. Submit → article parsed, deck/note created 63 + 64 + ### Import Lecture 65 + 66 + 1. Import page → "Lecture Import" tab 67 + 2. Enter lecture URL 68 + 3. Submit → lecture content extracted 69 + 70 + ## Study Session 71 + 72 + ### Daily Review 73 + 74 + 1. Navigate to `/review` or click "Review" in header 75 + 2. View study stats: due count, streak, reviewed today 76 + 3. Click "Start Study Session" 77 + 4. Card front shown → press **Space** to flip 78 + 5. View answer → grade with **1-5** keys 79 + 6. Repeat until all due cards complete 80 + 7. View completion message and updated stats 81 + 82 + ### Deck-Specific Review 83 + 84 + 1. Navigate to deck view (`/decks/:id`) 85 + 2. Click "Study Deck" 86 + 3. Review only cards from that deck 87 + 4. Same keyboard controls apply 88 + 89 + ### Progress Tracking 90 + 91 + - **Due count**: Cards needing review today 92 + - **Streak**: Consecutive days studied 93 + - **Reviewed today**: Cards completed this session 94 + - **Interval growth**: SM-2 algorithm increases intervals for mastered cards 95 + 96 + ### Keyboard Shortcuts 97 + 98 + | Key | Action | 99 + | ----- | -------------- | 100 + | Space | Flip card | 101 + | 1 | Grade: Again | 102 + | 2 | Grade: Hard | 103 + | 3 | Grade: Good | 104 + | 4 | Grade: Easy | 105 + | 5 | Grade: Perfect | 106 + | E | Quick edit | 107 + | Esc | Exit session |
+45
migrations/004_2025_12_30_srs_reviews.sql
··· 1 + -- SRS (Spaced Repetition System) schema 2 + -- 3 + -- Tracks per-user review state for each card 4 + 5 + CREATE TABLE card_reviews ( 6 + id UUID PRIMARY KEY, 7 + card_id UUID NOT NULL REFERENCES cards(id) ON DELETE CASCADE, 8 + user_did TEXT NOT NULL, 9 + -- SM-2 algorithm fields 10 + ease_factor REAL NOT NULL DEFAULT 2.5, 11 + interval_days INTEGER NOT NULL DEFAULT 0, 12 + repetitions INTEGER NOT NULL DEFAULT 0, 13 + -- Scheduling 14 + due_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 15 + last_reviewed_at TIMESTAMPTZ, 16 + -- Stats 17 + total_reviews INTEGER NOT NULL DEFAULT 0, 18 + -- Timestamps 19 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 20 + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 21 + -- Each user has one review state per card 22 + UNIQUE(card_id, user_did) 23 + ); 24 + 25 + CREATE INDEX idx_card_reviews_user_due ON card_reviews(user_did, due_at); 26 + CREATE INDEX idx_card_reviews_card_id ON card_reviews(card_id); 27 + 28 + CREATE TRIGGER update_card_reviews_updated_at BEFORE UPDATE ON card_reviews 29 + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); 30 + 31 + CREATE TABLE user_study_stats ( 32 + id UUID PRIMARY KEY, 33 + user_did TEXT NOT NULL UNIQUE, 34 + current_streak INTEGER NOT NULL DEFAULT 0, 35 + longest_streak INTEGER NOT NULL DEFAULT 0, 36 + last_study_date DATE, 37 + total_cards_reviewed INTEGER NOT NULL DEFAULT 0, 38 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 39 + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 40 + ); 41 + 42 + CREATE INDEX idx_user_study_stats_did ON user_study_stats(user_did); 43 + 44 + CREATE TRIGGER update_user_study_stats_updated_at BEFORE UPDATE ON user_study_stats 45 + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+3
web/src/App.tsx
··· 9 9 import Login from "$pages/Login"; 10 10 import NoteNew from "$pages/NoteNew"; 11 11 import NotFound from "$pages/NotFound"; 12 + import Review from "$pages/Review"; 12 13 import { Route, Router } from "@solidjs/router"; 13 14 import type { Component } from "solid-js"; 14 15 import { Show } from "solid-js"; ··· 34 35 <Route path="/decks/:id" component={() => <ProtectedRoute component={DeckView} />} /> 35 36 <Route path="/import" component={() => <ProtectedRoute component={Import} />} /> 36 37 <Route path="/import/lecture" component={() => <ProtectedRoute component={LectureImport} />} /> 38 + <Route path="/review" component={() => <ProtectedRoute component={Review} />} /> 39 + <Route path="/review/:deckId" component={() => <ProtectedRoute component={Review} />} /> 37 40 <Route path="*" component={() => <ProtectedRoute component={NotFound} />} /> 38 41 </Router> 39 42 );
+80
web/src/components/ReviewStats.tsx
··· 1 + import { fadeIn } from "$lib/animations"; 2 + import { api } from "$lib/api"; 3 + import type { ReviewCard, StudyStats } from "$lib/store"; 4 + import { Skeleton } from "$ui/Skeleton"; 5 + import type { Component } from "solid-js"; 6 + import { Motion } from "solid-motionone"; 7 + 8 + type ReviewStatsProps = { stats: StudyStats | null; loading?: boolean }; 9 + 10 + export const ReviewStats: Component<ReviewStatsProps> = (props) => { 11 + return ( 12 + <Motion.div {...fadeIn} class="bg-gray-900 rounded-xl p-6 border border-gray-800"> 13 + {/* TODO: use solid conditional components instead of ternary */} 14 + {props.loading 15 + ? ( 16 + <div class="space-y-4"> 17 + <Skeleton class="h-6 w-32" /> 18 + <Skeleton class="h-4 w-48" /> 19 + <Skeleton class="h-4 w-40" /> 20 + </div> 21 + ) 22 + : props.stats 23 + ? ( 24 + <div class="space-y-4"> 25 + <div class="flex items-center justify-between"> 26 + <h3 class="text-lg font-semibold text-white">Study Progress</h3> 27 + {/* TODO: fire icon */} 28 + <span class="text-2xl">🔥 {props.stats.current_streak} day streak</span> 29 + </div> 30 + 31 + <div class="grid grid-cols-3 gap-4 text-center"> 32 + <div class="bg-gray-800 rounded-lg p-4"> 33 + <p class="text-3xl font-bold text-blue-400">{props.stats.due_count}</p> 34 + <p class="text-sm text-gray-400">Due Today</p> 35 + </div> 36 + <div class="bg-gray-800 rounded-lg p-4"> 37 + <p class="text-3xl font-bold text-green-400">{props.stats.reviewed_today}</p> 38 + <p class="text-sm text-gray-400">Reviewed</p> 39 + </div> 40 + <div class="bg-gray-800 rounded-lg p-4"> 41 + <p class="text-3xl font-bold text-purple-400">{props.stats.total_reviews}</p> 42 + <p class="text-sm text-gray-400">Total</p> 43 + </div> 44 + </div> 45 + 46 + {props.stats.longest_streak > 0 && ( 47 + <p class="text-sm text-gray-500 text-center">Longest streak: {props.stats.longest_streak} days</p> 48 + )} 49 + </div> 50 + ) 51 + : <p class="text-gray-400">No stats available</p>} 52 + </Motion.div> 53 + ); 54 + }; 55 + 56 + // TODO: move this to api.ts 57 + export async function fetchStudyStats(): Promise<StudyStats | null> { 58 + try { 59 + const response = await api.getStats(); 60 + if (response.ok) { 61 + return response.json(); 62 + } 63 + } catch (err) { 64 + console.error("Failed to fetch stats:", err); 65 + } 66 + return null; 67 + } 68 + 69 + // TODO: move this to api.ts 70 + export async function fetchDueCards(deckId?: string): Promise<ReviewCard[]> { 71 + try { 72 + const response = await api.getDueCards(deckId); 73 + if (response.ok) { 74 + return response.json(); 75 + } 76 + } catch (err) { 77 + console.error("Failed to fetch due cards:", err); 78 + } 79 + return []; 80 + }
+94
web/src/components/StudySession.test.tsx
··· 1 + import type { ReviewCard } from "$lib/store"; 2 + import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library"; 3 + import { afterEach, describe, expect, it, vi } from "vitest"; 4 + import { StudySession } from "./StudySession"; 5 + 6 + vi.mock( 7 + "$lib/api", 8 + () => ({ api: { submitReview: vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) }) } }), 9 + ); 10 + 11 + const mockCards: ReviewCard[] = [{ 12 + review_id: "review-1", 13 + card_id: "card-1", 14 + deck_id: "deck-1", 15 + deck_title: "Test Deck", 16 + front: "What is 2+2?", 17 + back: "4", 18 + media_url: undefined, 19 + hints: [], 20 + due_at: new Date().toISOString(), 21 + }, { 22 + review_id: "review-2", 23 + card_id: "card-2", 24 + deck_id: "deck-1", 25 + deck_title: "Test Deck", 26 + front: "What is the capital of France?", 27 + back: "Paris", 28 + media_url: undefined, 29 + hints: ["It's a famous city"], 30 + due_at: new Date().toISOString(), 31 + }]; 32 + 33 + describe("StudySession", () => { 34 + afterEach(cleanup); 35 + 36 + it("renders card front initially", () => { 37 + const onComplete = vi.fn(); 38 + const onExit = vi.fn(); 39 + 40 + render(() => <StudySession cards={mockCards} onComplete={onComplete} onExit={onExit} />); 41 + 42 + expect(screen.getByText("What is 2+2?")).toBeInTheDocument(); 43 + expect(screen.getByText("Press Space or click to reveal")).toBeInTheDocument(); 44 + }); 45 + 46 + it("shows progress indicator", () => { 47 + const onComplete = vi.fn(); 48 + const onExit = vi.fn(); 49 + 50 + render(() => <StudySession cards={mockCards} onComplete={onComplete} onExit={onExit} />); 51 + 52 + expect(screen.getByText(/Card 1 of 2/i)).toBeInTheDocument(); 53 + }); 54 + 55 + it("flips card on click", async () => { 56 + const onComplete = vi.fn(); 57 + const onExit = vi.fn(); 58 + 59 + render(() => <StudySession cards={mockCards} onComplete={onComplete} onExit={onExit} />); 60 + 61 + expect(screen.getByText("What is 2+2?")).toBeInTheDocument(); 62 + 63 + const cardElement = screen.getByText("What is 2+2?").closest("div[class*='cursor-pointer']"); 64 + if (cardElement) { 65 + fireEvent.click(cardElement); 66 + } 67 + 68 + expect(await screen.findByText("How well did you know this?")).toBeInTheDocument(); 69 + }); 70 + 71 + it("shows keyboard hints", () => { 72 + const onComplete = vi.fn(); 73 + const onExit = vi.fn(); 74 + 75 + render(() => <StudySession cards={mockCards} onComplete={onComplete} onExit={onExit} />); 76 + 77 + expect(screen.getByText("Space: Flip")).toBeInTheDocument(); 78 + expect(screen.getByText("1-5: Grade")).toBeInTheDocument(); 79 + expect(screen.getByText("E: Edit")).toBeInTheDocument(); 80 + expect(screen.getByText("Esc: Exit")).toBeInTheDocument(); 81 + }); 82 + 83 + it("calls onExit when exit button is clicked", () => { 84 + const onComplete = vi.fn(); 85 + const onExit = vi.fn(); 86 + 87 + render(() => <StudySession cards={mockCards} onComplete={onComplete} onExit={onExit} />); 88 + 89 + const exitButton = screen.getByRole("button", { name: /✕ Exit/i }); 90 + fireEvent.click(exitButton); 91 + 92 + expect(onExit).toHaveBeenCalled(); 93 + }); 94 + });
+208
web/src/components/StudySession.tsx
··· 1 + import { scaleIn, slideInUp } from "$lib/animations"; 2 + import { api } from "$lib/api"; 3 + import type { Grade, ReviewCard } from "$lib/store"; 4 + import { Button } from "$ui/Button"; 5 + import { Dialog } from "$ui/Dialog"; 6 + import { ProgressBar } from "$ui/ProgressBar"; 7 + import { type Component, createEffect, createSignal, For, onCleanup, onMount, Show } from "solid-js"; 8 + import { Motion } from "solid-motionone"; 9 + 10 + type StudySessionProps = { cards: ReviewCard[]; onComplete: () => void; onExit: () => void }; 11 + 12 + const GRADE_LABELS: { [key in Grade]: { label: string; color: string; key: string } } = { 13 + 0: { label: "Again", color: "bg-red-600 hover:bg-red-500", key: "1" }, 14 + 1: { label: "Hard", color: "bg-orange-600 hover:bg-orange-500", key: "2" }, 15 + 2: { label: "Okay", color: "bg-yellow-600 hover:bg-yellow-500", key: "3" }, 16 + 3: { label: "Good", color: "bg-green-600 hover:bg-green-500", key: "4" }, 17 + 4: { label: "Easy", color: "bg-emerald-600 hover:bg-emerald-500", key: "5" }, 18 + 5: { label: "Perfect", color: "bg-cyan-600 hover:bg-cyan-500", key: "5" }, 19 + }; 20 + 21 + export const StudySession: Component<StudySessionProps> = (props) => { 22 + const [currentIndex, setCurrentIndex] = createSignal(0); 23 + const [isFlipped, setIsFlipped] = createSignal(false); 24 + const [isSubmitting, setIsSubmitting] = createSignal(false); 25 + const [showEditDialog, setShowEditDialog] = createSignal(false); 26 + 27 + const currentCard = () => props.cards[currentIndex()]; 28 + const progress = () => ((currentIndex() + 1) / props.cards.length) * 100; 29 + const isComplete = () => currentIndex() >= props.cards.length; 30 + 31 + const handleFlip = () => { 32 + if (!isFlipped()) { 33 + setIsFlipped(true); 34 + } 35 + }; 36 + 37 + const handleGrade = async (grade: Grade) => { 38 + const card = currentCard(); 39 + if (!card || isSubmitting()) return; 40 + 41 + setIsSubmitting(true); 42 + try { 43 + const response = await api.submitReview(card.card_id, grade); 44 + if (response.ok) { 45 + await response.json(); 46 + setIsFlipped(false); 47 + setCurrentIndex((i) => i + 1); 48 + } 49 + } catch (err) { 50 + console.error("Failed to submit review:", err); 51 + } finally { 52 + setIsSubmitting(false); 53 + } 54 + }; 55 + 56 + const handleKeyDown = (e: KeyboardEvent) => { 57 + if (showEditDialog()) return; 58 + 59 + switch (e.key) { 60 + case " ": 61 + e.preventDefault(); 62 + handleFlip(); 63 + break; 64 + case "1": 65 + if (isFlipped()) handleGrade(0); 66 + break; 67 + case "2": 68 + if (isFlipped()) handleGrade(1); 69 + break; 70 + case "3": 71 + if (isFlipped()) handleGrade(3); 72 + break; 73 + case "4": 74 + if (isFlipped()) handleGrade(4); 75 + break; 76 + case "5": 77 + if (isFlipped()) handleGrade(5); 78 + break; 79 + case "e": 80 + case "E": 81 + setShowEditDialog(true); 82 + break; 83 + case "Escape": 84 + props.onExit(); 85 + break; 86 + } 87 + }; 88 + 89 + onMount(() => { 90 + window.addEventListener("keydown", handleKeyDown); 91 + }); 92 + 93 + onCleanup(() => { 94 + window.removeEventListener("keydown", handleKeyDown); 95 + }); 96 + 97 + // Check for completion 98 + createEffect(() => { 99 + if (isComplete()) { 100 + props.onComplete(); 101 + } 102 + }); 103 + 104 + return ( 105 + <div class="min-h-screen bg-gray-950 flex flex-col items-center justify-center p-4"> 106 + {/* Progress Header */} 107 + <div class="w-full max-w-2xl mb-8"> 108 + <div class="flex items-center justify-between mb-2"> 109 + <span class="text-gray-400 text-sm">Card {currentIndex() + 1} of {props.cards.length}</span> 110 + <button onClick={() => props.onExit()} class="text-gray-400 hover:text-white text-sm flex items-center gap-1"> 111 + ✕ Exit <span class="text-xs text-gray-500">(Esc)</span> 112 + </button> 113 + </div> 114 + <ProgressBar value={progress()} color="green" size="md" /> 115 + </div> 116 + 117 + {/* Card */} 118 + <Show when={currentCard()}> 119 + {(card) => ( 120 + <Motion.div {...scaleIn} class="w-full max-w-2xl"> 121 + <div 122 + onClick={handleFlip} 123 + class="relative min-h-[400px] rounded-2xl cursor-pointer perspective-1000" 124 + style={{ "transform-style": "preserve-3d" }}> 125 + {/* Front */} 126 + <div 127 + class={`absolute inset-0 rounded-2xl bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 p-8 flex flex-col items-center justify-center backface-hidden transition-transform duration-400 ${ 128 + isFlipped() ? "rotate-y-180" : "" 129 + }`} 130 + style={{ "backface-visibility": "hidden" }}> 131 + <span class="text-xs text-gray-500 mb-4">{card().deck_title}</span> 132 + <p class="text-2xl text-white text-center font-medium">{card().front}</p> 133 + <Show when={!isFlipped()}> 134 + <p class="text-gray-500 mt-8 text-sm">Press Space or click to reveal</p> 135 + </Show> 136 + </div> 137 + 138 + {/* Back */} 139 + <div 140 + class={`absolute inset-0 rounded-2xl bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 p-8 flex flex-col items-center justify-center backface-hidden transition-transform duration-400 ${ 141 + isFlipped() ? "" : "rotate-y-180" 142 + }`} 143 + style={{ "backface-visibility": "hidden", transform: "rotateY(180deg)" }}> 144 + <span class="text-xs text-gray-500 mb-4">Answer</span> 145 + <p class="text-2xl text-white text-center font-medium">{card().back}</p> 146 + <Show when={card().hints.length > 0}> 147 + <div class="mt-4 text-sm text-gray-400"> 148 + <For each={card().hints}>{(hint) => <p class="italic">💡 {hint}</p>}</For> 149 + </div> 150 + </Show> 151 + </div> 152 + </div> 153 + </Motion.div> 154 + )} 155 + </Show> 156 + 157 + {/* Grade Buttons */} 158 + <Show when={isFlipped()}> 159 + <Motion.div {...slideInUp} class="w-full max-w-2xl mt-8"> 160 + <p class="text-center text-gray-400 text-sm mb-4">How well did you know this?</p> 161 + <div class="grid grid-cols-5 gap-2"> 162 + <For each={[0, 1, 3, 4, 5] as Grade[]}> 163 + {(grade) => ( 164 + <button 165 + onClick={() => handleGrade(grade)} 166 + disabled={isSubmitting()} 167 + class={`py-3 px-2 rounded-lg text-white font-medium transition-colors ${ 168 + GRADE_LABELS[grade].color 169 + } disabled:opacity-50`}> 170 + <span class="block text-lg">{GRADE_LABELS[grade].label}</span> 171 + <span class="block text-xs opacity-75">({GRADE_LABELS[grade].key})</span> 172 + </button> 173 + )} 174 + </For> 175 + </div> 176 + </Motion.div> 177 + </Show> 178 + 179 + {/* Keyboard Hints */} 180 + <div class="fixed bottom-4 left-1/2 -translate-x-1/2 text-gray-600 text-xs flex gap-4"> 181 + <span>Space: Flip</span> 182 + <span>1-5: Grade</span> 183 + <span>E: Edit</span> 184 + <span>Esc: Exit</span> 185 + </div> 186 + 187 + {/* Edit Dialog */} 188 + <Dialog open={showEditDialog()} onClose={() => setShowEditDialog(false)} title="Edit Card"> 189 + <Show when={currentCard()}> 190 + {(card) => ( 191 + <div class="space-y-4"> 192 + <div> 193 + <label class="block text-sm text-gray-400 mb-1">Front</label> 194 + <p class="text-white bg-gray-800 p-3 rounded">{card().front}</p> 195 + </div> 196 + <div> 197 + <label class="block text-sm text-gray-400 mb-1">Back</label> 198 + <p class="text-white bg-gray-800 p-3 rounded">{card().back}</p> 199 + </div> 200 + <p class="text-gray-500 text-sm">Full editing coming soon.</p> 201 + <Button onClick={() => setShowEditDialog(false)} variant="secondary" class="w-full">Close</Button> 202 + </div> 203 + )} 204 + </Show> 205 + </Dialog> 206 + </div> 207 + ); 208 + };
+21
web/src/lib/animations.ts
··· 50 50 51 51 /** Stagger delay for list items */ 52 52 export const staggerDelay = (index: number, baseDelay = 0.05) => index * baseDelay; 53 + 54 + /** Card flip animation (3D) */ 55 + export const cardFlip: MotionOptions = { 56 + initial: { rotateY: 0 }, 57 + animate: { rotateY: 180 }, 58 + transition: { duration: 0.4, easing: easeOut }, 59 + }; 60 + 61 + /** Slide out left (for card dismissal) */ 62 + export const slideOutLeft: MotionOptions = { 63 + initial: { opacity: 1, x: 0 }, 64 + animate: { opacity: 0, x: -100 }, 65 + transition: { duration: 0.25, easing: easeOut }, 66 + }; 67 + 68 + /** Bounce in (for success feedback) */ 69 + export const bounceIn: MotionOptions = { 70 + initial: { opacity: 0, scale: 0.8 }, 71 + animate: { opacity: 1, scale: 1 }, 72 + transition: { duration: 0.3, easing: [0.34, 1.56, 0.64, 1] }, 73 + };
+8
web/src/lib/api.ts
··· 27 27 export const api = { 28 28 get: (path: string) => apiFetch(path, { method: "GET" }), 29 29 post: (path: string, body: unknown) => apiFetch(path, { method: "POST", body: JSON.stringify(body) }), 30 + getDueCards: (deckId?: string, limit = 20) => { 31 + const params = new URLSearchParams({ limit: String(limit) }); 32 + if (deckId) params.set("deck_id", deckId); 33 + return apiFetch(`/review/due?${params}`, { method: "GET" }); 34 + }, 35 + submitReview: (cardId: string, grade: number) => 36 + apiFetch("/review/submit", { method: "POST", body: JSON.stringify({ card_id: cardId, grade }) }), 37 + getStats: () => apiFetch("/review/stats", { method: "GET" }), 30 38 };
+24
web/src/lib/store.ts
··· 75 75 visibility: Visibility; 76 76 cards: Card[]; 77 77 }; 78 + 79 + export type Grade = 0 | 1 | 2 | 3 | 4 | 5; 80 + 81 + export type ReviewCard = { 82 + review_id: string; 83 + card_id: string; 84 + deck_id: string; 85 + deck_title: string; 86 + front: string; 87 + back: string; 88 + media_url?: string; 89 + hints: string[]; 90 + due_at: string; 91 + }; 92 + 93 + export type StudyStats = { 94 + due_count: number; 95 + current_streak: number; 96 + longest_streak: number; 97 + reviewed_today: number; 98 + total_reviews: number; 99 + }; 100 + 101 + export type ReviewResponse = { ease_factor: number; interval_days: number; repetitions: number; due_at: string };
+118
web/src/pages/Review.tsx
··· 1 + import { fetchDueCards, fetchStudyStats, ReviewStats } from "$components/ReviewStats"; 2 + import { StudySession } from "$components/StudySession"; 3 + import { fadeIn } from "$lib/animations"; 4 + import type { ReviewCard, StudyStats as StudyStatsType } from "$lib/store"; 5 + import { Button } from "$ui/Button"; 6 + import { Skeleton } from "$ui/Skeleton"; 7 + import { useNavigate, useParams } from "@solidjs/router"; 8 + import { type Component, createSignal, onMount, Show } from "solid-js"; 9 + import { Motion } from "solid-motionone"; 10 + 11 + const Review: Component = () => { 12 + const params = useParams<{ deckId?: string }>(); 13 + const navigate = useNavigate(); 14 + 15 + const [cards, setCards] = createSignal<ReviewCard[]>([]); 16 + const [stats, setStats] = createSignal<StudyStatsType | null>(null); 17 + const [loading, setLoading] = createSignal(true); 18 + const [sessionActive, setSessionActive] = createSignal(false); 19 + const [sessionComplete, setSessionComplete] = createSignal(false); 20 + 21 + onMount(async () => { 22 + const [statsData, cardsData] = await Promise.all([fetchStudyStats(), fetchDueCards(params.deckId)]); 23 + setStats(statsData); 24 + setCards(cardsData); 25 + setLoading(false); 26 + }); 27 + 28 + const startSession = () => { 29 + if (cards().length > 0) { 30 + setSessionActive(true); 31 + setSessionComplete(false); 32 + } 33 + }; 34 + 35 + const handleComplete = async () => { 36 + setSessionActive(false); 37 + setSessionComplete(true); 38 + const newStats = await fetchStudyStats(); 39 + setStats(newStats); 40 + }; 41 + 42 + const handleExit = () => { 43 + setSessionActive(false); 44 + navigate("/"); 45 + }; 46 + 47 + return ( 48 + <Show 49 + when={!sessionActive()} 50 + fallback={<StudySession cards={cards()} onComplete={handleComplete} onExit={handleExit} />}> 51 + <Motion.div {...fadeIn} class="max-w-4xl mx-auto py-8 px-4"> 52 + <h1 class="text-3xl font-bold text-white mb-8">{params.deckId ? "Deck Review" : "Daily Review"}</h1> 53 + 54 + <ReviewStats stats={stats()} loading={loading()} /> 55 + 56 + <div class="mt-8 bg-gray-900 rounded-xl p-6 border border-gray-800"> 57 + <Show 58 + when={!loading()} 59 + fallback={ 60 + <div class="space-y-4"> 61 + <Skeleton class="h-8 w-48" /> 62 + <Skeleton class="h-12 w-full" /> 63 + </div> 64 + }> 65 + <Show 66 + when={cards().length > 0} 67 + fallback={ 68 + <div class="text-center py-8"> 69 + <p class="text-4xl mb-4">🎉</p> 70 + <h2 class="text-xl font-semibold text-white mb-2"> 71 + {sessionComplete() ? "Session Complete!" : "All Caught Up!"} 72 + </h2> 73 + <p class="text-gray-400 mb-6"> 74 + {sessionComplete() 75 + ? "Great job! You've reviewed all your due cards." 76 + : "You have no cards due for review right now."} 77 + </p> 78 + <Button onClick={() => navigate("/")} variant="secondary">Back to Library</Button> 79 + </div> 80 + }> 81 + <div class="text-center py-4"> 82 + <p class="text-lg text-white mb-2"> 83 + You have <span class="font-bold text-blue-400">{cards().length}</span> cards due 84 + </p> 85 + <p class="text-gray-400 text-sm mb-6">Use keyboard shortcuts for faster reviews</p> 86 + <Button onClick={startSession} class="px-8 py-3 text-lg">Start Study Session</Button> 87 + </div> 88 + </Show> 89 + </Show> 90 + </div> 91 + 92 + <div class="mt-8 bg-gray-900/50 rounded-xl p-6 border border-gray-800/50"> 93 + <h3 class="text-sm font-semibold text-gray-400 mb-4">Keyboard Shortcuts</h3> 94 + <div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm"> 95 + <div class="flex items-center gap-2"> 96 + <kbd class="px-2 py-1 bg-gray-800 rounded text-gray-300">Space</kbd> 97 + <span class="text-gray-400">Flip card</span> 98 + </div> 99 + <div class="flex items-center gap-2"> 100 + <kbd class="px-2 py-1 bg-gray-800 rounded text-gray-300">1-5</kbd> 101 + <span class="text-gray-400">Grade answer</span> 102 + </div> 103 + <div class="flex items-center gap-2"> 104 + <kbd class="px-2 py-1 bg-gray-800 rounded text-gray-300">E</kbd> 105 + <span class="text-gray-400">Edit card</span> 106 + </div> 107 + <div class="flex items-center gap-2"> 108 + <kbd class="px-2 py-1 bg-gray-800 rounded text-gray-300">Esc</kbd> 109 + <span class="text-gray-400">Exit session</span> 110 + </div> 111 + </div> 112 + </div> 113 + </Motion.div> 114 + </Show> 115 + ); 116 + }; 117 + 118 + export default Review;