···44edition = "2024"
5566[dependencies]
77+chrono = { version = "0.4", features = ["serde"] }
78serde = { version = "1.0.228", features = ["derive"] }
89serde_json = "1.0.148"
910thiserror = "2.0.17"
+2
crates/core/src/lib.rs
···11pub mod at_uri;
22pub mod error;
33pub mod model;
44+pub mod srs;
45pub mod tid;
5667pub use error::{Error, Result};
78pub use model::{Card, Deck, Note};
99+pub use srs::{Grade, ReviewState, Sm2Config};
+217
crates/core/src/srs.rs
···11+//! Spaced Repetition System (SM-2 Algorithm)
22+//!
33+//! Implements the SuperMemo 2 algorithm for scheduling card reviews.
44+//! Parameters are designed to be user-configurable in the future.
55+66+use chrono::{DateTime, Duration, Utc};
77+use serde::{Deserialize, Serialize};
88+99+/// Grade given by user during review (0-5 scale)
1010+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1111+#[serde(transparent)]
1212+pub struct Grade(pub u8);
1313+1414+impl Grade {
1515+ pub const AGAIN: Grade = Grade(0);
1616+ pub const HARD: Grade = Grade(1);
1717+ pub const GOOD: Grade = Grade(3);
1818+ pub const EASY: Grade = Grade(4);
1919+ pub const PERFECT: Grade = Grade(5);
2020+2121+ pub fn new(value: u8) -> Option<Self> {
2222+ if value <= 5 { Some(Grade(value)) } else { None }
2323+ }
2424+2525+ pub fn is_passing(&self) -> bool {
2626+ self.0 >= 3
2727+ }
2828+}
2929+3030+/// Default SM-2 parameters (user-configurable in future)
3131+#[derive(Debug, Clone, Serialize, Deserialize)]
3232+pub struct Sm2Config {
3333+ /// Initial ease factor for new cards
3434+ pub initial_ease: f32,
3535+ /// Minimum ease factor (prevents cards from becoming too hard)
3636+ pub min_ease: f32,
3737+ /// First interval in days after initial correct answer
3838+ pub first_interval: i32,
3939+ /// Second interval in days
4040+ pub second_interval: i32,
4141+}
4242+4343+impl Default for Sm2Config {
4444+ fn default() -> Self {
4545+ Self { initial_ease: 2.5, min_ease: 1.3, first_interval: 1, second_interval: 6 }
4646+ }
4747+}
4848+4949+/// Current review state for a card
5050+#[derive(Debug, Clone, Serialize, Deserialize)]
5151+pub struct ReviewState {
5252+ /// Ease factor (multiplier for interval)
5353+ pub ease_factor: f32,
5454+ /// Current interval in days
5555+ pub interval_days: i32,
5656+ /// Number of consecutive correct reviews
5757+ pub repetitions: i32,
5858+ /// When the card is due
5959+ pub due_at: DateTime<Utc>,
6060+}
6161+6262+impl Default for ReviewState {
6363+ fn default() -> Self {
6464+ Self { ease_factor: 2.5, interval_days: 0, repetitions: 0, due_at: Utc::now() }
6565+ }
6666+}
6767+6868+impl ReviewState {
6969+ /// Create a new review state for a fresh card
7070+ pub fn new() -> Self {
7171+ Self::default()
7272+ }
7373+7474+ /// Calculate next review state based on grade using SM-2 algorithm
7575+ pub fn schedule(&self, grade: Grade, config: &Sm2Config) -> Self {
7676+ let q = grade.0 as f32;
7777+7878+ // TODO: move to separate fn
7979+ // EF' = EF + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02))
8080+ let new_ease = self.ease_factor + (0.1 - (5.0 - q) * (0.08 + (5.0 - q) * 0.02));
8181+ let new_ease = new_ease.max(config.min_ease);
8282+8383+ if grade.is_passing() {
8484+ let (new_interval, new_reps) = match self.repetitions {
8585+ 0 => (config.first_interval, 1),
8686+ 1 => (config.second_interval, 2),
8787+ _ => {
8888+ let interval = (self.interval_days as f32 * new_ease).round() as i32;
8989+ (interval.max(1), self.repetitions + 1)
9090+ }
9191+ };
9292+9393+ Self {
9494+ ease_factor: new_ease,
9595+ interval_days: new_interval,
9696+ repetitions: new_reps,
9797+ due_at: Utc::now() + Duration::days(new_interval as i64),
9898+ }
9999+ } else {
100100+ Self { ease_factor: new_ease, interval_days: 0, repetitions: 0, due_at: Utc::now() + Duration::minutes(10) }
101101+ }
102102+ }
103103+}
104104+105105+#[cfg(test)]
106106+mod tests {
107107+ use super::*;
108108+109109+ #[test]
110110+ fn test_grade_validation() {
111111+ assert!(Grade::new(0).is_some());
112112+ assert!(Grade::new(5).is_some());
113113+ assert!(Grade::new(6).is_none());
114114+ }
115115+116116+ #[test]
117117+ fn test_grade_passing() {
118118+ assert!(!Grade::AGAIN.is_passing());
119119+ assert!(!Grade::HARD.is_passing());
120120+ assert!(Grade::GOOD.is_passing());
121121+ assert!(Grade::EASY.is_passing());
122122+ assert!(Grade::PERFECT.is_passing());
123123+ }
124124+125125+ #[test]
126126+ fn test_new_card_first_review_correct() {
127127+ let config = Sm2Config::default();
128128+ let state = ReviewState::new();
129129+130130+ let next = state.schedule(Grade::GOOD, &config);
131131+132132+ assert_eq!(next.interval_days, 1);
133133+ assert_eq!(next.repetitions, 1);
134134+ assert!(next.ease_factor >= 2.3 && next.ease_factor <= 2.7);
135135+ }
136136+137137+ #[test]
138138+ fn test_new_card_first_review_incorrect() {
139139+ let config = Sm2Config::default();
140140+ let state = ReviewState::new();
141141+142142+ let next = state.schedule(Grade::AGAIN, &config);
143143+144144+ assert_eq!(next.interval_days, 0);
145145+ assert_eq!(next.repetitions, 0);
146146+ let diff = next.due_at - Utc::now();
147147+ assert!(diff.num_minutes() <= 15);
148148+ }
149149+150150+ #[test]
151151+ fn test_second_review_correct() {
152152+ let config = Sm2Config::default();
153153+ let state = ReviewState { ease_factor: 2.5, interval_days: 1, repetitions: 1, due_at: Utc::now() };
154154+ let next = state.schedule(Grade::GOOD, &config);
155155+156156+ assert_eq!(next.interval_days, 6);
157157+ assert_eq!(next.repetitions, 2);
158158+ }
159159+160160+ #[test]
161161+ fn test_mature_card_interval_grows() {
162162+ let config = Sm2Config::default();
163163+ let state = ReviewState { ease_factor: 2.5, interval_days: 10, repetitions: 5, due_at: Utc::now() };
164164+ let next = state.schedule(Grade::GOOD, &config);
165165+166166+ assert!(next.interval_days >= 20);
167167+ assert_eq!(next.repetitions, 6);
168168+ }
169169+170170+ #[test]
171171+ fn test_ease_factor_minimum() {
172172+ let config = Sm2Config::default();
173173+ let state = ReviewState { ease_factor: 1.4, interval_days: 5, repetitions: 3, due_at: Utc::now() };
174174+ let next = state.schedule(Grade::GOOD, &config);
175175+176176+ assert!(next.ease_factor >= config.min_ease);
177177+ }
178178+179179+ #[test]
180180+ fn test_easy_increases_ease() {
181181+ let config = Sm2Config::default();
182182+ let state = ReviewState::new();
183183+184184+ let next = state.schedule(Grade::PERFECT, &config);
185185+186186+ assert!(next.ease_factor > 2.5);
187187+ }
188188+189189+ #[test]
190190+ fn test_hard_decreases_ease() {
191191+ let config = Sm2Config::default();
192192+ let state = ReviewState { ease_factor: 2.5, interval_days: 10, repetitions: 5, due_at: Utc::now() };
193193+ let next = state.schedule(Grade::HARD, &config);
194194+195195+ assert!(next.ease_factor < 2.5);
196196+ }
197197+198198+ #[test]
199199+ fn test_30_day_simulation() {
200200+ let config = Sm2Config::default();
201201+ let mut state = ReviewState::new();
202202+203203+ state = state.schedule(Grade::GOOD, &config);
204204+ assert_eq!(state.interval_days, 1);
205205+206206+ state = state.schedule(Grade::GOOD, &config);
207207+ assert_eq!(state.interval_days, 6);
208208+209209+ state = state.schedule(Grade::GOOD, &config);
210210+ assert!(state.interval_days >= 12 && state.interval_days <= 20);
211211+212212+ state = state.schedule(Grade::GOOD, &config);
213213+ assert!(state.interval_days >= 20);
214214+ assert_eq!(state.repetitions, 4);
215215+ assert!(state.ease_factor >= config.min_ease);
216216+ }
217217+}
+1
crates/server/src/api/mod.rs
···44pub mod importer;
55pub mod note;
66pub mod oauth;
77+pub mod review;
+234
crates/server/src/api/review.rs
···11+use crate::middleware::auth::UserContext;
22+use crate::repository::review::ReviewRepoError;
33+use crate::state::SharedState;
44+55+use axum::{
66+ Json,
77+ extract::{Extension, Query, State},
88+ http::StatusCode,
99+ response::IntoResponse,
1010+};
1111+use malfestio_core::srs::Grade;
1212+use serde::{Deserialize, Serialize};
1313+use serde_json::json;
1414+1515+#[derive(Deserialize)]
1616+pub struct DueCardsQuery {
1717+ deck_id: Option<String>,
1818+ #[serde(default = "default_limit")]
1919+ limit: i64,
2020+}
2121+2222+fn default_limit() -> i64 {
2323+ 20
2424+}
2525+2626+#[derive(Deserialize)]
2727+pub struct SubmitReviewRequest {
2828+ card_id: String,
2929+ grade: u8,
3030+}
3131+3232+#[derive(Serialize)]
3333+pub struct SubmitReviewResponse {
3434+ ease_factor: f32,
3535+ interval_days: i32,
3636+ repetitions: i32,
3737+ due_at: String,
3838+}
3939+4040+/// GET /api/review/due - Get cards due for review
4141+pub async fn get_due_cards(
4242+ State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, Query(query): Query<DueCardsQuery>,
4343+) -> impl IntoResponse {
4444+ let user = match ctx {
4545+ Some(Extension(user)) => user,
4646+ None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(),
4747+ };
4848+4949+ let result = state
5050+ .review_repo
5151+ .get_due_cards(&user.did, query.deck_id.as_deref(), query.limit)
5252+ .await;
5353+5454+ match result {
5555+ Ok(cards) => Json(cards).into_response(),
5656+ Err(ReviewRepoError::InvalidArgument(msg)) => {
5757+ (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response()
5858+ }
5959+ Err(e) => {
6060+ tracing::error!("Failed to get due cards: {:?}", e);
6161+ (
6262+ StatusCode::INTERNAL_SERVER_ERROR,
6363+ Json(json!({"error": "Failed to get due cards"})),
6464+ )
6565+ .into_response()
6666+ }
6767+ }
6868+}
6969+7070+/// POST /api/review/submit - Submit a review grade
7171+pub async fn submit_review(
7272+ State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, Json(payload): Json<SubmitReviewRequest>,
7373+) -> impl IntoResponse {
7474+ let user = match ctx {
7575+ Some(Extension(user)) => user,
7676+ None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(),
7777+ };
7878+7979+ let grade = match Grade::new(payload.grade) {
8080+ Some(g) => g,
8181+ None => return (StatusCode::BAD_REQUEST, Json(json!({"error": "Grade must be 0-5"}))).into_response(),
8282+ };
8383+8484+ let result = state
8585+ .review_repo
8686+ .submit_review(&user.did, &payload.card_id, grade)
8787+ .await;
8888+8989+ match result {
9090+ Ok(new_state) => Json(SubmitReviewResponse {
9191+ ease_factor: new_state.ease_factor,
9292+ interval_days: new_state.interval_days,
9393+ repetitions: new_state.repetitions,
9494+ due_at: new_state.due_at.to_rfc3339(),
9595+ })
9696+ .into_response(),
9797+ Err(ReviewRepoError::InvalidArgument(msg)) => {
9898+ (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response()
9999+ }
100100+ Err(e) => {
101101+ tracing::error!("Failed to submit review: {:?}", e);
102102+ (
103103+ StatusCode::INTERNAL_SERVER_ERROR,
104104+ Json(json!({"error": "Failed to submit review"})),
105105+ )
106106+ .into_response()
107107+ }
108108+ }
109109+}
110110+111111+/// GET /api/review/stats - Get user study statistics
112112+pub async fn get_stats(State(state): State<SharedState>, ctx: Option<Extension<UserContext>>) -> impl IntoResponse {
113113+ let user = match ctx {
114114+ Some(Extension(user)) => user,
115115+ None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(),
116116+ };
117117+118118+ let result = state.review_repo.get_stats(&user.did).await;
119119+120120+ match result {
121121+ Ok(stats) => Json(stats).into_response(),
122122+ Err(e) => {
123123+ tracing::error!("Failed to get stats: {:?}", e);
124124+ (
125125+ StatusCode::INTERNAL_SERVER_ERROR,
126126+ Json(json!({"error": "Failed to get stats"})),
127127+ )
128128+ .into_response()
129129+ }
130130+ }
131131+}
132132+133133+#[cfg(test)]
134134+mod tests {
135135+ use super::*;
136136+ use crate::repository::card::mock::MockCardRepository;
137137+ use crate::repository::note::mock::MockNoteRepository;
138138+ use crate::repository::oauth::mock::MockOAuthRepository;
139139+ use crate::repository::review::mock::MockReviewRepository;
140140+ use crate::repository::review::{ReviewCard, ReviewRepository};
141141+ use crate::state::AppState;
142142+ use chrono::Utc;
143143+ use std::sync::Arc;
144144+145145+ fn create_test_state_with_review(review_repo: Arc<dyn ReviewRepository>) -> SharedState {
146146+ let pool = crate::db::create_mock_pool();
147147+ let card_repo = Arc::new(MockCardRepository::new()) as Arc<dyn crate::repository::card::CardRepository>;
148148+ let note_repo = Arc::new(MockNoteRepository::new()) as Arc<dyn crate::repository::note::NoteRepository>;
149149+ let oauth_repo = Arc::new(MockOAuthRepository::new()) as Arc<dyn crate::repository::oauth::OAuthRepository>;
150150+151151+ Arc::new(AppState { pool, card_repo, note_repo, oauth_repo, review_repo })
152152+ }
153153+154154+ #[tokio::test]
155155+ async fn test_get_due_cards_unauthorized() {
156156+ let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn ReviewRepository>;
157157+ let state = create_test_state_with_review(review_repo);
158158+159159+ let response = get_due_cards(State(state), None, Query(DueCardsQuery { deck_id: None, limit: 20 }))
160160+ .await
161161+ .into_response();
162162+163163+ assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
164164+ }
165165+166166+ #[tokio::test]
167167+ async fn test_get_due_cards_success() {
168168+ let cards = vec![ReviewCard {
169169+ review_id: "review-1".to_string(),
170170+ card_id: "card-1".to_string(),
171171+ deck_id: "deck-1".to_string(),
172172+ deck_title: "Test Deck".to_string(),
173173+ front: "What is 2+2?".to_string(),
174174+ back: "4".to_string(),
175175+ media_url: None,
176176+ hints: vec![],
177177+ due_at: Utc::now(),
178178+ }];
179179+ let review_repo = Arc::new(MockReviewRepository::with_cards(cards)) as Arc<dyn ReviewRepository>;
180180+ let state = create_test_state_with_review(review_repo);
181181+182182+ let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() };
183183+ let response = get_due_cards(
184184+ State(state),
185185+ Some(Extension(user)),
186186+ Query(DueCardsQuery { deck_id: None, limit: 20 }),
187187+ )
188188+ .await
189189+ .into_response();
190190+191191+ assert_eq!(response.status(), StatusCode::OK);
192192+ }
193193+194194+ #[tokio::test]
195195+ async fn test_submit_review_success() {
196196+ let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn ReviewRepository>;
197197+ let state = create_test_state_with_review(review_repo);
198198+199199+ let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() };
200200+ let payload = SubmitReviewRequest { card_id: "card-1".to_string(), grade: 3 };
201201+202202+ let response = submit_review(State(state), Some(Extension(user)), Json(payload))
203203+ .await
204204+ .into_response();
205205+206206+ assert_eq!(response.status(), StatusCode::OK);
207207+ }
208208+209209+ #[tokio::test]
210210+ async fn test_submit_review_invalid_grade() {
211211+ let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn ReviewRepository>;
212212+ let state = create_test_state_with_review(review_repo);
213213+214214+ let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() };
215215+ let payload = SubmitReviewRequest { card_id: "card-1".to_string(), grade: 10 };
216216+217217+ let response = submit_review(State(state), Some(Extension(user)), Json(payload))
218218+ .await
219219+ .into_response();
220220+221221+ assert_eq!(response.status(), StatusCode::BAD_REQUEST);
222222+ }
223223+224224+ #[tokio::test]
225225+ async fn test_get_stats_success() {
226226+ let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn ReviewRepository>;
227227+ let state = create_test_state_with_review(review_repo);
228228+229229+ let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() };
230230+ let response = get_stats(State(state), Some(Extension(user))).await.into_response();
231231+232232+ assert_eq!(response.status(), StatusCode::OK);
233233+ }
234234+}
···11pub mod card;
22pub mod note;
33pub mod oauth;
44+pub mod review;
+378
crates/server/src/repository/review.rs
···11+use async_trait::async_trait;
22+use chrono::{DateTime, Utc};
33+use malfestio_core::srs::{Grade, ReviewState, Sm2Config};
44+use serde::{Deserialize, Serialize};
55+66+#[derive(Debug)]
77+pub enum ReviewRepoError {
88+ DatabaseError(String),
99+ NotFound(String),
1010+ InvalidArgument(String),
1111+}
1212+1313+/// Card with review state for study sessions
1414+#[derive(Debug, Clone, Serialize, Deserialize)]
1515+pub struct ReviewCard {
1616+ pub review_id: String,
1717+ pub card_id: String,
1818+ pub deck_id: String,
1919+ pub deck_title: String,
2020+ pub front: String,
2121+ pub back: String,
2222+ pub media_url: Option<String>,
2323+ pub hints: Vec<String>,
2424+ pub due_at: DateTime<Utc>,
2525+}
2626+2727+/// User study statistics
2828+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2929+pub struct StudyStats {
3030+ pub due_count: i64,
3131+ pub current_streak: i32,
3232+ pub longest_streak: i32,
3333+ pub reviewed_today: i64,
3434+ pub total_reviews: i64,
3535+}
3636+3737+#[async_trait]
3838+pub trait ReviewRepository: Send + Sync {
3939+ /// Get cards due for review, optionally filtered by deck
4040+ async fn get_due_cards(
4141+ &self, user_did: &str, deck_id: Option<&str>, limit: i64,
4242+ ) -> Result<Vec<ReviewCard>, ReviewRepoError>;
4343+4444+ /// Submit a review grade for a card
4545+ async fn submit_review(&self, user_did: &str, card_id: &str, grade: Grade) -> Result<ReviewState, ReviewRepoError>;
4646+4747+ /// Get study statistics for a user
4848+ async fn get_stats(&self, user_did: &str) -> Result<StudyStats, ReviewRepoError>;
4949+}
5050+5151+pub struct DbReviewRepository {
5252+ pool: crate::db::DbPool,
5353+ config: Sm2Config,
5454+}
5555+5656+impl DbReviewRepository {
5757+ pub fn new(pool: crate::db::DbPool) -> Self {
5858+ Self { pool, config: Sm2Config::default() }
5959+ }
6060+}
6161+6262+#[async_trait]
6363+impl ReviewRepository for DbReviewRepository {
6464+ async fn get_due_cards(
6565+ &self, user_did: &str, deck_id: Option<&str>, limit: i64,
6666+ ) -> Result<Vec<ReviewCard>, ReviewRepoError> {
6767+ let client = self
6868+ .pool
6969+ .get()
7070+ .await
7171+ .map_err(|e| ReviewRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?;
7272+7373+ let now = Utc::now();
7474+7575+ let rows = if let Some(deck_id) = deck_id {
7676+ let deck_uuid = uuid::Uuid::parse_str(deck_id)
7777+ .map_err(|_| ReviewRepoError::InvalidArgument("Invalid deck ID".to_string()))?;
7878+7979+ client
8080+ .query(
8181+ r#"
8282+ SELECT
8383+ cr.id as review_id,
8484+ c.id as card_id,
8585+ c.deck_id,
8686+ d.title as deck_title,
8787+ c.front,
8888+ c.back,
8989+ c.media_url,
9090+ cr.due_at
9191+ FROM cards c
9292+ JOIN decks d ON c.deck_id = d.id
9393+ LEFT JOIN card_reviews cr ON c.id = cr.card_id AND cr.user_did = $1
9494+ WHERE c.deck_id = $2
9595+ AND (cr.due_at IS NULL OR cr.due_at <= $3)
9696+ ORDER BY COALESCE(cr.due_at, '1970-01-01'::timestamptz) ASC
9797+ LIMIT $4
9898+ "#,
9999+ &[&user_did, &deck_uuid, &now, &limit],
100100+ )
101101+ .await
102102+ } else {
103103+ client
104104+ .query(
105105+ r#"
106106+ SELECT
107107+ cr.id as review_id,
108108+ c.id as card_id,
109109+ c.deck_id,
110110+ d.title as deck_title,
111111+ c.front,
112112+ c.back,
113113+ c.media_url,
114114+ cr.due_at
115115+ FROM cards c
116116+ JOIN decks d ON c.deck_id = d.id
117117+ LEFT JOIN card_reviews cr ON c.id = cr.card_id AND cr.user_did = $1
118118+ WHERE d.owner_did = $1
119119+ AND (cr.due_at IS NULL OR cr.due_at <= $2)
120120+ ORDER BY COALESCE(cr.due_at, '1970-01-01'::timestamptz) ASC
121121+ LIMIT $3
122122+ "#,
123123+ &[&user_did, &now, &limit],
124124+ )
125125+ .await
126126+ };
127127+128128+ let rows = rows.map_err(|e| ReviewRepoError::DatabaseError(format!("Failed to query cards: {}", e)))?;
129129+130130+ let mut cards = Vec::new();
131131+ for row in rows {
132132+ let review_id: Option<uuid::Uuid> = row.get("review_id");
133133+ let card_id: uuid::Uuid = row.get("card_id");
134134+ let deck_id: uuid::Uuid = row.get("deck_id");
135135+ let due_at: Option<DateTime<Utc>> = row.get("due_at");
136136+137137+ cards.push(ReviewCard {
138138+ review_id: review_id.map(|id| id.to_string()).unwrap_or_default(),
139139+ card_id: card_id.to_string(),
140140+ deck_id: deck_id.to_string(),
141141+ deck_title: row.get("deck_title"),
142142+ front: row.get("front"),
143143+ back: row.get("back"),
144144+ media_url: row.get("media_url"),
145145+ // TODO: Load hints when stored in DB
146146+ hints: vec![],
147147+ due_at: due_at.unwrap_or_else(Utc::now),
148148+ });
149149+ }
150150+151151+ Ok(cards)
152152+ }
153153+154154+ async fn submit_review(&self, user_did: &str, card_id: &str, grade: Grade) -> Result<ReviewState, ReviewRepoError> {
155155+ let client = self
156156+ .pool
157157+ .get()
158158+ .await
159159+ .map_err(|e| ReviewRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?;
160160+161161+ let card_uuid = uuid::Uuid::parse_str(card_id)
162162+ .map_err(|_| ReviewRepoError::InvalidArgument("Invalid card ID".to_string()))?;
163163+164164+ let existing = client
165165+ .query_opt(
166166+ "SELECT id, ease_factor, interval_days, repetitions, due_at FROM card_reviews WHERE card_id = $1 AND user_did = $2",
167167+ &[&card_uuid, &user_did],
168168+ )
169169+ .await
170170+ .map_err(|e| ReviewRepoError::DatabaseError(format!("Failed to query review: {}", e)))?;
171171+172172+ let current_state = existing
173173+ .map(|row| ReviewState {
174174+ ease_factor: row.get::<_, f32>("ease_factor"),
175175+ interval_days: row.get::<_, i32>("interval_days"),
176176+ repetitions: row.get::<_, i32>("repetitions"),
177177+ due_at: row.get("due_at"),
178178+ })
179179+ .unwrap_or_default();
180180+181181+ let new_state = current_state.schedule(grade, &self.config);
182182+ let now = Utc::now();
183183+184184+ client
185185+ .execute(
186186+ r#"
187187+ INSERT INTO card_reviews (id, card_id, user_did, ease_factor, interval_days, repetitions, due_at, last_reviewed_at, total_reviews)
188188+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 1)
189189+ ON CONFLICT (card_id, user_did) DO UPDATE SET
190190+ ease_factor = $4,
191191+ interval_days = $5,
192192+ repetitions = $6,
193193+ due_at = $7,
194194+ last_reviewed_at = $8,
195195+ total_reviews = card_reviews.total_reviews + 1
196196+ "#,
197197+ &[
198198+ &uuid::Uuid::new_v4(),
199199+ &card_uuid,
200200+ &user_did,
201201+ &new_state.ease_factor,
202202+ &new_state.interval_days,
203203+ &new_state.repetitions,
204204+ &new_state.due_at,
205205+ &now,
206206+ ],
207207+ )
208208+ .await
209209+ .map_err(|e| ReviewRepoError::DatabaseError(format!("Failed to update review: {}", e)))?;
210210+211211+ let today = now.date_naive();
212212+ client
213213+ .execute(
214214+ r#"
215215+ INSERT INTO user_study_stats (id, user_did, current_streak, longest_streak, last_study_date, total_cards_reviewed)
216216+ VALUES ($1, $2, 1, 1, $3, 1)
217217+ ON CONFLICT (user_did) DO UPDATE SET
218218+ current_streak = CASE
219219+ WHEN user_study_stats.last_study_date = $3 THEN user_study_stats.current_streak
220220+ WHEN user_study_stats.last_study_date = $3 - INTERVAL '1 day' THEN user_study_stats.current_streak + 1
221221+ ELSE 1
222222+ END,
223223+ longest_streak = GREATEST(user_study_stats.longest_streak,
224224+ CASE
225225+ WHEN user_study_stats.last_study_date = $3 THEN user_study_stats.current_streak
226226+ WHEN user_study_stats.last_study_date = $3 - INTERVAL '1 day' THEN user_study_stats.current_streak + 1
227227+ ELSE 1
228228+ END
229229+ ),
230230+ last_study_date = $3,
231231+ total_cards_reviewed = user_study_stats.total_cards_reviewed + 1
232232+ "#,
233233+ &[&uuid::Uuid::new_v4(), &user_did, &today],
234234+ )
235235+ .await
236236+ .map_err(|e| ReviewRepoError::DatabaseError(format!("Failed to update stats: {}", e)))?;
237237+238238+ Ok(new_state)
239239+ }
240240+241241+ async fn get_stats(&self, user_did: &str) -> Result<StudyStats, ReviewRepoError> {
242242+ let client = self
243243+ .pool
244244+ .get()
245245+ .await
246246+ .map_err(|e| ReviewRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?;
247247+248248+ let now = Utc::now();
249249+ let today = now.date_naive();
250250+251251+ let due_row = client
252252+ .query_one(
253253+ r#"
254254+ SELECT COUNT(*) as due_count FROM cards c
255255+ JOIN decks d ON c.deck_id = d.id
256256+ LEFT JOIN card_reviews cr ON c.id = cr.card_id AND cr.user_did = $1
257257+ WHERE d.owner_did = $1
258258+ AND (cr.due_at IS NULL OR cr.due_at <= $2)
259259+ "#,
260260+ &[&user_did, &now],
261261+ )
262262+ .await
263263+ .map_err(|e| ReviewRepoError::DatabaseError(format!("Failed to count due cards: {}", e)))?;
264264+265265+ let due_count: i64 = due_row.get("due_count");
266266+267267+ let reviewed_row = client
268268+ .query_one(
269269+ r#"
270270+ SELECT COUNT(*) as reviewed_count FROM card_reviews
271271+ WHERE user_did = $1 AND DATE(last_reviewed_at) = $2
272272+ "#,
273273+ &[&user_did, &today],
274274+ )
275275+ .await
276276+ .map_err(|e| ReviewRepoError::DatabaseError(format!("Failed to count reviews: {}", e)))?;
277277+278278+ let reviewed_today: i64 = reviewed_row.get("reviewed_count");
279279+280280+ let stats_row = client
281281+ .query_opt(
282282+ "SELECT current_streak, longest_streak, total_cards_reviewed FROM user_study_stats WHERE user_did = $1",
283283+ &[&user_did],
284284+ )
285285+ .await
286286+ .map_err(|e| ReviewRepoError::DatabaseError(format!("Failed to get stats: {}", e)))?;
287287+288288+ let (current_streak, longest_streak, total_reviews) = stats_row
289289+ .map(|row| {
290290+ (
291291+ row.get::<_, i32>("current_streak"),
292292+ row.get::<_, i32>("longest_streak"),
293293+ row.get::<_, i32>("total_cards_reviewed") as i64,
294294+ )
295295+ })
296296+ .unwrap_or((0, 0, 0));
297297+298298+ Ok(StudyStats { due_count, current_streak, longest_streak, reviewed_today, total_reviews })
299299+ }
300300+}
301301+302302+#[cfg(test)]
303303+pub mod mock {
304304+ use super::*;
305305+ use std::sync::{Arc, Mutex};
306306+307307+ #[derive(Clone)]
308308+ pub struct MockReviewRepository {
309309+ pub cards: Arc<Mutex<Vec<ReviewCard>>>,
310310+ pub should_fail: Arc<Mutex<bool>>,
311311+ }
312312+313313+ impl MockReviewRepository {
314314+ pub fn new() -> Self {
315315+ Self { cards: Arc::new(Mutex::new(Vec::new())), should_fail: Arc::new(Mutex::new(false)) }
316316+ }
317317+318318+ pub fn with_cards(cards: Vec<ReviewCard>) -> Self {
319319+ Self { cards: Arc::new(Mutex::new(cards)), should_fail: Arc::new(Mutex::new(false)) }
320320+ }
321321+322322+ #[allow(dead_code)]
323323+ pub fn set_should_fail(&self, should_fail: bool) {
324324+ *self.should_fail.lock().unwrap() = should_fail;
325325+ }
326326+ }
327327+328328+ impl Default for MockReviewRepository {
329329+ fn default() -> Self {
330330+ Self::new()
331331+ }
332332+ }
333333+334334+ #[async_trait]
335335+ impl ReviewRepository for MockReviewRepository {
336336+ async fn get_due_cards(
337337+ &self, _user_did: &str, deck_id: Option<&str>, limit: i64,
338338+ ) -> Result<Vec<ReviewCard>, ReviewRepoError> {
339339+ if *self.should_fail.lock().unwrap() {
340340+ return Err(ReviewRepoError::DatabaseError("Mock failure".to_string()));
341341+ }
342342+343343+ let cards = self.cards.lock().unwrap();
344344+ let filtered: Vec<_> = cards
345345+ .iter()
346346+ .filter(|c| deck_id.is_none_or(|id| c.deck_id == id))
347347+ .take(limit as usize)
348348+ .cloned()
349349+ .collect();
350350+ Ok(filtered)
351351+ }
352352+353353+ async fn submit_review(
354354+ &self, _user_did: &str, _card_id: &str, grade: Grade,
355355+ ) -> Result<ReviewState, ReviewRepoError> {
356356+ if *self.should_fail.lock().unwrap() {
357357+ return Err(ReviewRepoError::DatabaseError("Mock failure".to_string()));
358358+ }
359359+360360+ let state = ReviewState::default();
361361+ Ok(state.schedule(grade, &Sm2Config::default()))
362362+ }
363363+364364+ async fn get_stats(&self, _user_did: &str) -> Result<StudyStats, ReviewRepoError> {
365365+ if *self.should_fail.lock().unwrap() {
366366+ return Err(ReviewRepoError::DatabaseError("Mock failure".to_string()));
367367+ }
368368+369369+ Ok(StudyStats {
370370+ due_count: self.cards.lock().unwrap().len() as i64,
371371+ current_streak: 5,
372372+ longest_streak: 10,
373373+ reviewed_today: 3,
374374+ total_reviews: 100,
375375+ })
376376+ }
377377+ }
378378+}
+7-2
crates/server/src/state.rs
···22use crate::repository::card::{CardRepository, DbCardRepository};
33use crate::repository::note::{DbNoteRepository, NoteRepository};
44use crate::repository::oauth::{DbOAuthRepository, OAuthRepository};
55+use crate::repository::review::{DbReviewRepository, ReviewRepository};
56use std::sync::Arc;
6778pub type SharedState = Arc<AppState>;
···1112 pub card_repo: Arc<dyn CardRepository>,
1213 pub note_repo: Arc<dyn NoteRepository>,
1314 pub oauth_repo: Arc<dyn OAuthRepository>,
1515+ pub review_repo: Arc<dyn ReviewRepository>,
1416}
15171618impl AppState {
···1820 let card_repo = Arc::new(DbCardRepository::new(pool.clone())) as Arc<dyn CardRepository>;
1921 let note_repo = Arc::new(DbNoteRepository::new(pool.clone())) as Arc<dyn NoteRepository>;
2022 let oauth_repo = Arc::new(DbOAuthRepository::new(pool.clone())) as Arc<dyn OAuthRepository>;
2323+ let review_repo = Arc::new(DbReviewRepository::new(pool.clone())) as Arc<dyn ReviewRepository>;
21242222- Arc::new(Self { pool, card_repo, note_repo, oauth_repo })
2525+ Arc::new(Self { pool, card_repo, note_repo, oauth_repo, review_repo })
2326 }
24272528 #[cfg(test)]
···2730 pool: DbPool, card_repo: Arc<dyn CardRepository>, note_repo: Arc<dyn NoteRepository>,
2831 oauth_repo: Arc<dyn OAuthRepository>,
2932 ) -> SharedState {
3030- Arc::new(Self { pool, card_repo, note_repo, oauth_repo })
3333+ let review_repo =
3434+ Arc::new(crate::repository::review::mock::MockReviewRepository::new()) as Arc<dyn ReviewRepository>;
3535+ Arc::new(Self { pool, card_repo, note_repo, oauth_repo, review_repo })
3136 }
3237}
+6-20
docs/todo.md
···5353- **(Done) Milestone E**: Internal component library/UI Foundation + Animations.
5454- **(Done) Milestone F**: Content Authoring (Notes + Cards + Deck Builder).
55555656-### Milestone G - Study Engine (SRS) + Daily Review UX
5757-5858-#### Deliverables
5959-6060-- SRS scheduler (SM-2 baseline)
6161- - grade 0–5, EF, interval, repetition count
6262-- Review queue generation rules
6363-- Study session UI:
6464- - keyboard-first review loop
6565- - quick edit card during review
6666-- Progress views (private):
6767- - due count, retention proxy, streaks
6868-6969-#### Acceptance
7070-7171-- 30-day simulated study test produces stable, believable intervals.
7272-7373-#### Notes
7474-7575-- SM-2 reference behavior is well documented; start there and iterate.
5656+- **(Done) Milestone G**: Study Engine (SRS) + Daily Review UX.
5757+ - SM-2 spaced repetition scheduler.
5858+ - Review repository with due card queries and stats tracking.
5959+ - API endpoints: `/review/due`, `/review/submit`, `/review/stats`.
6060+ - StudySession component with keyboard-first review.
6161+ - ReviewStats component for progress display.
76627763### Milestone H - Social Layer v1 (Follow, Feed, Fork, Comments)
7864
+107
docs/user-flows.md
···11+# User Flows
22+33+User experience pathways for Malfestio.
44+55+## Authentication
66+77+### Login
88+99+1. Navigate to `/login`
1010+2. Enter Bluesky handle and app password
1111+3. Submit → redirected to Library
1212+1313+### Logout
1414+1515+1. Click avatar in header → "Logout"
1616+2. → redirected to Landing page
1717+1818+## Deck Management
1919+2020+### Create Deck
2121+2222+1. Library (`/`) → "Create Deck"
2323+2. Fill: title, description, tags
2424+3. Set visibility (Private/Unlisted/Public/SharedWith)
2525+4. Add cards (front/back, optional hints, card type)
2626+5. Submit → deck created, redirected to Library
2727+2828+### View Deck
2929+3030+1. Library → click deck card
3131+2. View title, description, tags, card list
3232+3. Options: Edit, Study, Back to Library
3333+3434+### Study Deck
3535+3636+1. Deck View → "Study Deck"
3737+2. Study session with keyboard controls
3838+3. Grade cards (1-5), view progress
3939+4. Session complete → return to deck
4040+4141+## Note Management
4242+4343+### Create Note
4444+4545+1. Header → "Notes" → "New Note"
4646+2. Fill: title, body (markdown), tags
4747+3. Add wikilinks with `[[Note Title]]`
4848+4. Set visibility
4949+5. Submit → note created
5050+5151+### View Notes
5252+5353+1. Header → "Notes"
5454+2. Browse notes with backlink navigation
5555+5656+## Content Import
5757+5858+### Import Article
5959+6060+1. Header → "Import"
6161+2. Enter article URL
6262+3. Submit → article parsed, deck/note created
6363+6464+### Import Lecture
6565+6666+1. Import page → "Lecture Import" tab
6767+2. Enter lecture URL
6868+3. Submit → lecture content extracted
6969+7070+## Study Session
7171+7272+### Daily Review
7373+7474+1. Navigate to `/review` or click "Review" in header
7575+2. View study stats: due count, streak, reviewed today
7676+3. Click "Start Study Session"
7777+4. Card front shown → press **Space** to flip
7878+5. View answer → grade with **1-5** keys
7979+6. Repeat until all due cards complete
8080+7. View completion message and updated stats
8181+8282+### Deck-Specific Review
8383+8484+1. Navigate to deck view (`/decks/:id`)
8585+2. Click "Study Deck"
8686+3. Review only cards from that deck
8787+4. Same keyboard controls apply
8888+8989+### Progress Tracking
9090+9191+- **Due count**: Cards needing review today
9292+- **Streak**: Consecutive days studied
9393+- **Reviewed today**: Cards completed this session
9494+- **Interval growth**: SM-2 algorithm increases intervals for mastered cards
9595+9696+### Keyboard Shortcuts
9797+9898+| Key | Action |
9999+| ----- | -------------- |
100100+| Space | Flip card |
101101+| 1 | Grade: Again |
102102+| 2 | Grade: Hard |
103103+| 3 | Grade: Good |
104104+| 4 | Grade: Easy |
105105+| 5 | Grade: Perfect |
106106+| E | Quick edit |
107107+| Esc | Exit session |
+45
migrations/004_2025_12_30_srs_reviews.sql
···11+-- SRS (Spaced Repetition System) schema
22+--
33+-- Tracks per-user review state for each card
44+55+CREATE TABLE card_reviews (
66+ id UUID PRIMARY KEY,
77+ card_id UUID NOT NULL REFERENCES cards(id) ON DELETE CASCADE,
88+ user_did TEXT NOT NULL,
99+ -- SM-2 algorithm fields
1010+ ease_factor REAL NOT NULL DEFAULT 2.5,
1111+ interval_days INTEGER NOT NULL DEFAULT 0,
1212+ repetitions INTEGER NOT NULL DEFAULT 0,
1313+ -- Scheduling
1414+ due_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
1515+ last_reviewed_at TIMESTAMPTZ,
1616+ -- Stats
1717+ total_reviews INTEGER NOT NULL DEFAULT 0,
1818+ -- Timestamps
1919+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
2020+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
2121+ -- Each user has one review state per card
2222+ UNIQUE(card_id, user_did)
2323+);
2424+2525+CREATE INDEX idx_card_reviews_user_due ON card_reviews(user_did, due_at);
2626+CREATE INDEX idx_card_reviews_card_id ON card_reviews(card_id);
2727+2828+CREATE TRIGGER update_card_reviews_updated_at BEFORE UPDATE ON card_reviews
2929+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
3030+3131+CREATE TABLE user_study_stats (
3232+ id UUID PRIMARY KEY,
3333+ user_did TEXT NOT NULL UNIQUE,
3434+ current_streak INTEGER NOT NULL DEFAULT 0,
3535+ longest_streak INTEGER NOT NULL DEFAULT 0,
3636+ last_study_date DATE,
3737+ total_cards_reviewed INTEGER NOT NULL DEFAULT 0,
3838+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
3939+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
4040+);
4141+4242+CREATE INDEX idx_user_study_stats_did ON user_study_stats(user_did);
4343+4444+CREATE TRIGGER update_user_study_stats_updated_at BEFORE UPDATE ON user_study_stats
4545+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();