+1
Cargo.lock
+1
Cargo.lock
+1
crates/core/Cargo.toml
+1
crates/core/Cargo.toml
+2
crates/core/src/lib.rs
+2
crates/core/src/lib.rs
+217
crates/core/src/srs.rs
+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
+1
crates/server/src/api/mod.rs
+234
crates/server/src/api/review.rs
+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
+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
crates/server/src/repository/mod.rs
+378
crates/server/src/repository/review.rs
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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;