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

feat: user prefs API

* help & onboarding

+1479 -116
+1 -1
crates/server/src/api/auth.rs
··· 1 1 use crate::state::SharedState; 2 - use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; 3 2 3 + use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; 4 4 use serde::{Deserialize, Serialize}; 5 5 use serde_json::json; 6 6
+3
crates/server/src/api/feed.rs
··· 60 60 let card_repo = Arc::new(MockCardRepository::new()) as Arc<dyn crate::repository::card::CardRepository>; 61 61 let note_repo = Arc::new(MockNoteRepository::new()) as Arc<dyn crate::repository::note::NoteRepository>; 62 62 let oauth_repo = Arc::new(MockOAuthRepository::new()) as Arc<dyn crate::repository::oauth::OAuthRepository>; 63 + let prefs_repo = Arc::new(crate::repository::preferences::mock::MockPreferencesRepository::new()) 64 + as Arc<dyn crate::repository::preferences::PreferencesRepository>; 63 65 let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn crate::repository::review::ReviewRepository>; 64 66 65 67 let deck_repo = Arc::new(crate::repository::deck::mock::MockDeckRepository::new()) ··· 73 75 card: card_repo, 74 76 note: note_repo, 75 77 oauth: oauth_repo, 78 + prefs: prefs_repo, 76 79 review: review_repo, 77 80 social: social_repo, 78 81 deck: deck_repo,
+1
crates/server/src/api/mod.rs
··· 5 5 pub mod importer; 6 6 pub mod note; 7 7 pub mod oauth; 8 + pub mod preferences; 8 9 pub mod review; 9 10 pub mod search; 10 11 pub mod social;
+194
crates/server/src/api/preferences.rs
··· 1 + use crate::middleware::auth::UserContext; 2 + use crate::repository::preferences::{PreferencesRepoError, UpdatePreferences}; 3 + use crate::state::SharedState; 4 + 5 + use axum::{ 6 + Json, 7 + extract::{Extension, State}, 8 + http::StatusCode, 9 + response::IntoResponse, 10 + }; 11 + use serde::Deserialize; 12 + use serde_json::json; 13 + 14 + #[derive(Deserialize)] 15 + pub struct UpdatePreferencesRequest { 16 + pub persona: Option<String>, 17 + pub complete_onboarding: Option<bool>, 18 + pub tutorial_deck_completed: Option<bool>, 19 + } 20 + 21 + /// GET /api/preferences - Get current user preferences 22 + pub async fn get_preferences( 23 + State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, 24 + ) -> impl IntoResponse { 25 + let user = match ctx { 26 + Some(Extension(user)) => user, 27 + None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), 28 + }; 29 + 30 + let result = state.prefs_repo.get_or_create(&user.did).await; 31 + 32 + match result { 33 + Ok(prefs) => Json(prefs).into_response(), 34 + Err(e) => { 35 + tracing::error!("Failed to get preferences: {:?}", e); 36 + ( 37 + StatusCode::INTERNAL_SERVER_ERROR, 38 + Json(json!({"error": "Failed to get preferences"})), 39 + ) 40 + .into_response() 41 + } 42 + } 43 + } 44 + 45 + /// PUT /api/preferences - Update user preferences 46 + pub async fn update_preferences( 47 + State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, 48 + Json(payload): Json<UpdatePreferencesRequest>, 49 + ) -> impl IntoResponse { 50 + let user = match ctx { 51 + Some(Extension(user)) => user, 52 + None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), 53 + }; 54 + 55 + let persona = if let Some(ref p) = payload.persona { 56 + match p.parse() { 57 + Ok(persona) => Some(persona), 58 + Err(_) => { 59 + return ( 60 + StatusCode::BAD_REQUEST, 61 + Json(json!({"error": "Invalid persona. Must be 'learner', 'creator', or 'curator'"})), 62 + ) 63 + .into_response(); 64 + } 65 + } 66 + } else { 67 + None 68 + }; 69 + 70 + let updates = UpdatePreferences { 71 + persona, 72 + complete_onboarding: payload.complete_onboarding, 73 + tutorial_deck_completed: payload.tutorial_deck_completed, 74 + }; 75 + 76 + let result = state.prefs_repo.update(&user.did, updates).await; 77 + 78 + match result { 79 + Ok(prefs) => Json(prefs).into_response(), 80 + Err(PreferencesRepoError::NotFound(msg)) => { 81 + (StatusCode::NOT_FOUND, Json(json!({"error": msg}))).into_response() 82 + } 83 + Err(e) => { 84 + tracing::error!("Failed to update preferences: {:?}", e); 85 + ( 86 + StatusCode::INTERNAL_SERVER_ERROR, 87 + Json(json!({"error": "Failed to update preferences"})), 88 + ) 89 + .into_response() 90 + } 91 + } 92 + } 93 + 94 + #[cfg(test)] 95 + mod tests { 96 + use super::*; 97 + use crate::repository::preferences::PreferencesRepository; 98 + use crate::repository::preferences::mock::MockPreferencesRepository; 99 + use crate::state::{AppConfig, AppState, Repositories}; 100 + use std::sync::Arc; 101 + 102 + fn create_test_state_with_prefs(prefs_repo: Arc<dyn PreferencesRepository>) -> SharedState { 103 + let pool = crate::db::create_mock_pool(); 104 + let card_repo = Arc::new(crate::repository::card::mock::MockCardRepository::new()) 105 + as Arc<dyn crate::repository::card::CardRepository>; 106 + let note_repo = Arc::new(crate::repository::note::mock::MockNoteRepository::new()) 107 + as Arc<dyn crate::repository::note::NoteRepository>; 108 + let oauth_repo = Arc::new(crate::repository::oauth::mock::MockOAuthRepository::new()) 109 + as Arc<dyn crate::repository::oauth::OAuthRepository>; 110 + let social_repo = Arc::new(crate::repository::social::mock::MockSocialRepository::new()) 111 + as Arc<dyn crate::repository::social::SocialRepository>; 112 + let deck_repo = Arc::new(crate::repository::deck::mock::MockDeckRepository::new()) 113 + as Arc<dyn crate::repository::deck::DeckRepository>; 114 + let search_repo = Arc::new(crate::repository::search::mock::MockSearchRepository::new()) 115 + as Arc<dyn crate::repository::search::SearchRepository>; 116 + let review_repo = Arc::new(crate::repository::review::mock::MockReviewRepository::new()) 117 + as Arc<dyn crate::repository::review::ReviewRepository>; 118 + 119 + let config = AppConfig { pds_url: "https://bsky.social".to_string() }; 120 + 121 + let repos = Repositories { 122 + card: card_repo, 123 + note: note_repo, 124 + oauth: oauth_repo, 125 + review: review_repo, 126 + social: social_repo, 127 + deck: deck_repo, 128 + search: search_repo, 129 + prefs: prefs_repo, 130 + }; 131 + 132 + AppState::new(pool, repos, config) 133 + } 134 + 135 + #[tokio::test] 136 + async fn test_get_preferences_unauthorized() { 137 + let prefs_repo = Arc::new(MockPreferencesRepository::new()) as Arc<dyn PreferencesRepository>; 138 + let state = create_test_state_with_prefs(prefs_repo); 139 + 140 + let response = get_preferences(State(state), None).await.into_response(); 141 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 142 + } 143 + 144 + #[tokio::test] 145 + async fn test_get_preferences_success() { 146 + let prefs_repo = Arc::new(MockPreferencesRepository::new()) as Arc<dyn PreferencesRepository>; 147 + let state = create_test_state_with_prefs(prefs_repo); 148 + 149 + let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() }; 150 + let response = get_preferences(State(state), Some(Extension(user))) 151 + .await 152 + .into_response(); 153 + 154 + assert_eq!(response.status(), StatusCode::OK); 155 + } 156 + 157 + #[tokio::test] 158 + async fn test_update_preferences_set_persona() { 159 + let prefs_repo = Arc::new(MockPreferencesRepository::new()) as Arc<dyn PreferencesRepository>; 160 + let state = create_test_state_with_prefs(prefs_repo); 161 + 162 + let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() }; 163 + let payload = UpdatePreferencesRequest { 164 + persona: Some("creator".to_string()), 165 + complete_onboarding: Some(true), 166 + tutorial_deck_completed: None, 167 + }; 168 + 169 + let response = update_preferences(State(state), Some(Extension(user)), Json(payload)) 170 + .await 171 + .into_response(); 172 + 173 + assert_eq!(response.status(), StatusCode::OK); 174 + } 175 + 176 + #[tokio::test] 177 + async fn test_update_preferences_invalid_persona() { 178 + let prefs_repo = Arc::new(MockPreferencesRepository::new()) as Arc<dyn PreferencesRepository>; 179 + let state = create_test_state_with_prefs(prefs_repo); 180 + 181 + let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() }; 182 + let payload = UpdatePreferencesRequest { 183 + persona: Some("invalid".to_string()), 184 + complete_onboarding: None, 185 + tutorial_deck_completed: None, 186 + }; 187 + 188 + let response = update_preferences(State(state), Some(Extension(user)), Json(payload)) 189 + .await 190 + .into_response(); 191 + 192 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 193 + } 194 + }
+3
crates/server/src/api/review.rs
··· 147 147 let card_repo = Arc::new(MockCardRepository::new()) as Arc<dyn crate::repository::card::CardRepository>; 148 148 let note_repo = Arc::new(MockNoteRepository::new()) as Arc<dyn crate::repository::note::NoteRepository>; 149 149 let oauth_repo = Arc::new(MockOAuthRepository::new()) as Arc<dyn crate::repository::oauth::OAuthRepository>; 150 + let preferences_repo = Arc::new(crate::repository::preferences::mock::MockPreferencesRepository::new()) 151 + as Arc<dyn crate::repository::preferences::PreferencesRepository>; 150 152 let social_repo = Arc::new(crate::repository::social::mock::MockSocialRepository::new()) 151 153 as Arc<dyn crate::repository::social::SocialRepository>; 152 154 ··· 161 163 card: card_repo, 162 164 note: note_repo, 163 165 oauth: oauth_repo, 166 + prefs: preferences_repo, 164 167 review: review_repo, 165 168 social: social_repo, 166 169 deck: deck_repo,
+3 -1
crates/server/src/api/search.rs
··· 90 90 let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn crate::repository::review::ReviewRepository>; 91 91 let social_repo = Arc::new(MockSocialRepository::new()) as Arc<dyn crate::repository::social::SocialRepository>; 92 92 let deck_repo = Arc::new(MockDeckRepository::new()) as Arc<dyn crate::repository::deck::DeckRepository>; 93 - 94 93 let config = crate::state::AppConfig { pds_url: "https://bsky.social".to_string() }; 95 94 let auth_cache = Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())); 96 95 let search_repo_trait = search_repo.clone() as Arc<dyn SearchRepository>; 96 + let prefs_repo = Arc::new(crate::repository::preferences::mock::MockPreferencesRepository::new()) 97 + as Arc<dyn crate::repository::preferences::PreferencesRepository>; 97 98 98 99 Arc::new(AppState { 99 100 pool, 100 101 card_repo, 101 102 note_repo, 102 103 oauth_repo, 104 + prefs_repo, 103 105 review_repo, 104 106 social_repo, 105 107 deck_repo,
+3
crates/server/src/api/social.rs
··· 185 185 let card_repo = Arc::new(MockCardRepository::new()) as Arc<dyn crate::repository::card::CardRepository>; 186 186 let note_repo = Arc::new(MockNoteRepository::new()) as Arc<dyn crate::repository::note::NoteRepository>; 187 187 let oauth_repo = Arc::new(MockOAuthRepository::new()) as Arc<dyn crate::repository::oauth::OAuthRepository>; 188 + let preferences_repo = Arc::new(crate::repository::preferences::mock::MockPreferencesRepository::new()) 189 + as Arc<dyn crate::repository::preferences::PreferencesRepository>; 188 190 let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn crate::repository::review::ReviewRepository>; 189 191 190 192 let deck_repo = Arc::new(crate::repository::deck::mock::MockDeckRepository::new()) ··· 199 201 card_repo, 200 202 note_repo, 201 203 oauth_repo, 204 + prefs_repo: preferences_repo, 202 205 review_repo, 203 206 social_repo, 204 207 deck_repo,
+4
crates/server/src/lib.rs
··· 47 47 let deck_repo = std::sync::Arc::new(repository::deck::DbDeckRepository::new(pool.clone())); 48 48 let card_repo = std::sync::Arc::new(repository::card::DbCardRepository::new(pool.clone())); 49 49 let note_repo = std::sync::Arc::new(repository::note::DbNoteRepository::new(pool.clone())); 50 + let prefs_repo = std::sync::Arc::new(repository::preferences::DbPreferencesRepository::new(pool.clone())); 50 51 let review_repo = std::sync::Arc::new(repository::review::DbReviewRepository::new(pool.clone())); 51 52 let social_repo = std::sync::Arc::new(repository::social::DbSocialRepository::new(pool.clone())); 52 53 ··· 59 60 deck: deck_repo, 60 61 card: card_repo, 61 62 note: note_repo, 63 + prefs: prefs_repo, 62 64 review: review_repo, 63 65 social: social_repo, 64 66 search: search_repo, ··· 81 83 .route("/social/unfollow/{did}", post(api::social::unfollow)) 82 84 .route("/decks/{id}/comments", post(api::social::add_comment)) 83 85 .route("/feeds/follows", get(api::feed::get_feed_follows)) 86 + .route("/preferences", get(api::preferences::get_preferences)) 87 + .route("/preferences", axum::routing::put(api::preferences::update_preferences)) 84 88 .layer(axum_middleware::from_fn_with_state( 85 89 state.clone(), 86 90 middleware::auth::auth_middleware,
+1
crates/server/src/repository/mod.rs
··· 2 2 pub mod deck; 3 3 pub mod note; 4 4 pub mod oauth; 5 + pub mod preferences; 5 6 pub mod review; 6 7 pub mod search; 7 8 pub mod social;
+334
crates/server/src/repository/preferences.rs
··· 1 + use async_trait::async_trait; 2 + use chrono::{DateTime, Utc}; 3 + use serde::{Deserialize, Serialize}; 4 + 5 + #[derive(Debug)] 6 + pub enum PreferencesRepoError { 7 + DatabaseError(String), 8 + NotFound(String), 9 + } 10 + 11 + /// User persona for personalized experience 12 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 13 + #[serde(rename_all = "lowercase")] 14 + pub enum Persona { 15 + Learner, 16 + Creator, 17 + Curator, 18 + } 19 + 20 + impl std::fmt::Display for Persona { 21 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 22 + match self { 23 + Persona::Learner => write!(f, "learner"), 24 + Persona::Creator => write!(f, "creator"), 25 + Persona::Curator => write!(f, "curator"), 26 + } 27 + } 28 + } 29 + 30 + impl std::str::FromStr for Persona { 31 + type Err = String; 32 + 33 + fn from_str(s: &str) -> Result<Self, Self::Err> { 34 + match s.to_lowercase().as_str() { 35 + "learner" => Ok(Persona::Learner), 36 + "creator" => Ok(Persona::Creator), 37 + "curator" => Ok(Persona::Curator), 38 + _ => Err(format!("Invalid persona: {}", s)), 39 + } 40 + } 41 + } 42 + 43 + /// User preferences for onboarding and personalization 44 + #[derive(Debug, Clone, Default, Serialize, Deserialize)] 45 + pub struct UserPreferences { 46 + #[serde(default)] 47 + pub user_did: String, 48 + pub persona: Option<Persona>, 49 + pub onboarding_completed_at: Option<DateTime<Utc>>, 50 + #[serde(default)] 51 + pub tutorial_deck_completed: bool, 52 + } 53 + 54 + /// Update request for user preferences 55 + #[derive(Debug, Clone, Serialize, Deserialize)] 56 + pub struct UpdatePreferences { 57 + pub persona: Option<Persona>, 58 + pub complete_onboarding: Option<bool>, 59 + pub tutorial_deck_completed: Option<bool>, 60 + } 61 + 62 + #[async_trait] 63 + pub trait PreferencesRepository: Send + Sync { 64 + /// Get user preferences, creating default if not exists 65 + async fn get_or_create(&self, user_did: &str) -> Result<UserPreferences, PreferencesRepoError>; 66 + 67 + /// Update user preferences 68 + async fn update(&self, user_did: &str, updates: UpdatePreferences) 69 + -> Result<UserPreferences, PreferencesRepoError>; 70 + } 71 + 72 + pub struct DbPreferencesRepository { 73 + pool: crate::db::DbPool, 74 + } 75 + 76 + impl DbPreferencesRepository { 77 + pub fn new(pool: crate::db::DbPool) -> Self { 78 + Self { pool } 79 + } 80 + } 81 + 82 + #[async_trait] 83 + impl PreferencesRepository for DbPreferencesRepository { 84 + async fn get_or_create(&self, user_did: &str) -> Result<UserPreferences, PreferencesRepoError> { 85 + let client = self 86 + .pool 87 + .get() 88 + .await 89 + .map_err(|e| PreferencesRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 90 + 91 + // Try to get existing preferences 92 + let row = client 93 + .query_opt( 94 + "SELECT user_did, persona, onboarding_completed_at, tutorial_deck_completed FROM user_prefs WHERE user_did = $1", 95 + &[&user_did], 96 + ) 97 + .await 98 + .map_err(|e| PreferencesRepoError::DatabaseError(format!("Failed to query preferences: {}", e)))?; 99 + 100 + if let Some(row) = row { 101 + let persona_str: Option<String> = row.get("persona"); 102 + let persona = persona_str.and_then(|s| s.parse().ok()); 103 + 104 + return Ok(UserPreferences { 105 + user_did: row.get("user_did"), 106 + persona, 107 + onboarding_completed_at: row.get("onboarding_completed_at"), 108 + tutorial_deck_completed: row.get("tutorial_deck_completed"), 109 + }); 110 + } 111 + 112 + // Create default preferences 113 + client 114 + .execute( 115 + "INSERT INTO user_prefs (id, user_did) VALUES ($1, $2) ON CONFLICT (user_did) DO NOTHING", 116 + &[&uuid::Uuid::new_v4(), &user_did], 117 + ) 118 + .await 119 + .map_err(|e| PreferencesRepoError::DatabaseError(format!("Failed to create preferences: {}", e)))?; 120 + 121 + Ok(UserPreferences { user_did: user_did.to_string(), ..Default::default() }) 122 + } 123 + 124 + async fn update( 125 + &self, user_did: &str, updates: UpdatePreferences, 126 + ) -> Result<UserPreferences, PreferencesRepoError> { 127 + let client = self 128 + .pool 129 + .get() 130 + .await 131 + .map_err(|e| PreferencesRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 132 + 133 + // Ensure record exists first 134 + client 135 + .execute( 136 + "INSERT INTO user_prefs (id, user_did) VALUES ($1, $2) ON CONFLICT (user_did) DO NOTHING", 137 + &[&uuid::Uuid::new_v4(), &user_did], 138 + ) 139 + .await 140 + .map_err(|e| PreferencesRepoError::DatabaseError(format!("Failed to ensure preferences: {}", e)))?; 141 + 142 + // Build update query dynamically 143 + let mut set_clauses = Vec::new(); 144 + let mut param_idx = 2; 145 + 146 + let persona_str = updates.persona.map(|p| p.to_string()); 147 + if updates.persona.is_some() { 148 + set_clauses.push(format!("persona = ${}", param_idx)); 149 + param_idx += 1; 150 + } 151 + 152 + let now = Utc::now(); 153 + let complete_onboarding = updates.complete_onboarding.unwrap_or(false); 154 + 155 + if updates.tutorial_deck_completed.is_some() { 156 + set_clauses.push(format!("tutorial_deck_completed = ${}", param_idx)); 157 + param_idx += 1; 158 + } 159 + 160 + if complete_onboarding { 161 + set_clauses.push(format!("onboarding_completed_at = ${}", param_idx)); 162 + } 163 + 164 + if set_clauses.is_empty() { 165 + return self.get_or_create(user_did).await; 166 + } 167 + 168 + // Build params list - need to handle owned values 169 + let mut param_vec: Vec<Box<dyn tokio_postgres::types::ToSql + Sync + Send>> = Vec::new(); 170 + param_vec.push(Box::new(user_did.to_string())); 171 + 172 + if let Some(ref persona) = persona_str { 173 + param_vec.push(Box::new(persona.clone())); 174 + } 175 + 176 + if let Some(tutorial) = updates.tutorial_deck_completed { 177 + param_vec.push(Box::new(tutorial)); 178 + } 179 + 180 + if complete_onboarding { 181 + param_vec.push(Box::new(now)); 182 + } 183 + 184 + let params_refs: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = param_vec 185 + .iter() 186 + .map(|p| p.as_ref() as &(dyn tokio_postgres::types::ToSql + Sync)) 187 + .collect(); 188 + 189 + let query = format!("UPDATE user_prefs SET {} WHERE user_did = $1", set_clauses.join(", ")); 190 + 191 + client 192 + .execute(&query, &params_refs) 193 + .await 194 + .map_err(|e| PreferencesRepoError::DatabaseError(format!("Failed to update preferences: {}", e)))?; 195 + 196 + self.get_or_create(user_did).await 197 + } 198 + } 199 + 200 + #[cfg(test)] 201 + pub mod mock { 202 + use super::*; 203 + use std::sync::{Arc, Mutex}; 204 + 205 + #[derive(Clone)] 206 + pub struct MockPreferencesRepository { 207 + pub prefs: Arc<Mutex<std::collections::HashMap<String, UserPreferences>>>, 208 + pub should_fail: Arc<Mutex<bool>>, 209 + } 210 + 211 + impl MockPreferencesRepository { 212 + pub fn new() -> Self { 213 + Self { 214 + prefs: Arc::new(Mutex::new(std::collections::HashMap::new())), 215 + should_fail: Arc::new(Mutex::new(false)), 216 + } 217 + } 218 + 219 + #[allow(dead_code)] 220 + pub fn set_should_fail(&self, should_fail: bool) { 221 + *self.should_fail.lock().unwrap() = should_fail; 222 + } 223 + } 224 + 225 + impl Default for MockPreferencesRepository { 226 + fn default() -> Self { 227 + Self::new() 228 + } 229 + } 230 + 231 + #[async_trait] 232 + impl PreferencesRepository for MockPreferencesRepository { 233 + async fn get_or_create(&self, user_did: &str) -> Result<UserPreferences, PreferencesRepoError> { 234 + if *self.should_fail.lock().unwrap() { 235 + return Err(PreferencesRepoError::DatabaseError("Mock failure".to_string())); 236 + } 237 + 238 + let mut prefs = self.prefs.lock().unwrap(); 239 + let entry = prefs 240 + .entry(user_did.to_string()) 241 + .or_insert_with(|| UserPreferences { user_did: user_did.to_string(), ..Default::default() }); 242 + Ok(entry.clone()) 243 + } 244 + 245 + async fn update( 246 + &self, user_did: &str, updates: UpdatePreferences, 247 + ) -> Result<UserPreferences, PreferencesRepoError> { 248 + if *self.should_fail.lock().unwrap() { 249 + return Err(PreferencesRepoError::DatabaseError("Mock failure".to_string())); 250 + } 251 + 252 + let mut prefs = self.prefs.lock().unwrap(); 253 + let entry = prefs 254 + .entry(user_did.to_string()) 255 + .or_insert_with(|| UserPreferences { user_did: user_did.to_string(), ..Default::default() }); 256 + 257 + if let Some(persona) = updates.persona { 258 + entry.persona = Some(persona); 259 + } 260 + 261 + if updates.complete_onboarding.unwrap_or(false) { 262 + entry.onboarding_completed_at = Some(Utc::now()); 263 + } 264 + 265 + if let Some(tutorial) = updates.tutorial_deck_completed { 266 + entry.tutorial_deck_completed = tutorial; 267 + } 268 + 269 + Ok(entry.clone()) 270 + } 271 + } 272 + } 273 + 274 + #[cfg(test)] 275 + mod tests { 276 + use super::mock::MockPreferencesRepository; 277 + use super::*; 278 + 279 + #[tokio::test] 280 + async fn test_get_or_create_returns_default() { 281 + let repo = MockPreferencesRepository::new(); 282 + let prefs = repo.get_or_create("did:plc:test").await.unwrap(); 283 + 284 + assert_eq!(prefs.user_did, "did:plc:test"); 285 + assert!(prefs.persona.is_none()); 286 + assert!(prefs.onboarding_completed_at.is_none()); 287 + assert!(!prefs.tutorial_deck_completed); 288 + } 289 + 290 + #[tokio::test] 291 + async fn test_update_persona() { 292 + let repo = MockPreferencesRepository::new(); 293 + let prefs = repo 294 + .update( 295 + "did:plc:test", 296 + UpdatePreferences { 297 + persona: Some(Persona::Creator), 298 + complete_onboarding: None, 299 + tutorial_deck_completed: None, 300 + }, 301 + ) 302 + .await 303 + .unwrap(); 304 + 305 + assert_eq!(prefs.persona, Some(Persona::Creator)); 306 + } 307 + 308 + #[tokio::test] 309 + async fn test_complete_onboarding() { 310 + let repo = MockPreferencesRepository::new(); 311 + let prefs = repo 312 + .update( 313 + "did:plc:test", 314 + UpdatePreferences { 315 + persona: Some(Persona::Learner), 316 + complete_onboarding: Some(true), 317 + tutorial_deck_completed: None, 318 + }, 319 + ) 320 + .await 321 + .unwrap(); 322 + 323 + assert!(prefs.onboarding_completed_at.is_some()); 324 + assert_eq!(prefs.persona, Some(Persona::Learner)); 325 + } 326 + 327 + #[tokio::test] 328 + async fn test_persona_parse() { 329 + assert_eq!("learner".parse::<Persona>().unwrap(), Persona::Learner); 330 + assert_eq!("creator".parse::<Persona>().unwrap(), Persona::Creator); 331 + assert_eq!("curator".parse::<Persona>().unwrap(), Persona::Curator); 332 + assert!("invalid".parse::<Persona>().is_err()); 333 + } 334 + }
+8
crates/server/src/state.rs
··· 4 4 use crate::repository::deck::DeckRepository; 5 5 use crate::repository::note::NoteRepository; 6 6 use crate::repository::oauth::OAuthRepository; 7 + use crate::repository::preferences::PreferencesRepository; 7 8 use crate::repository::review::ReviewRepository; 8 9 use crate::repository::search::SearchRepository; 9 10 use crate::repository::social::SocialRepository; ··· 27 28 pub deck: Arc<dyn DeckRepository>, 28 29 pub card: Arc<dyn CardRepository>, 29 30 pub note: Arc<dyn NoteRepository>, 31 + pub prefs: Arc<dyn PreferencesRepository>, 30 32 pub review: Arc<dyn ReviewRepository>, 31 33 pub social: Arc<dyn SocialRepository>, 32 34 pub search: Arc<dyn SearchRepository>, ··· 38 40 pub deck_repo: Arc<dyn DeckRepository>, 39 41 pub note_repo: Arc<dyn NoteRepository>, 40 42 pub oauth_repo: Arc<dyn OAuthRepository>, 43 + pub prefs_repo: Arc<dyn PreferencesRepository>, 41 44 pub review_repo: Arc<dyn ReviewRepository>, 42 45 pub social_repo: Arc<dyn SocialRepository>, 43 46 pub search_repo: Arc<dyn SearchRepository>, ··· 54 57 deck_repo: repos.deck, 55 58 card_repo: repos.card, 56 59 note_repo: repos.note, 60 + prefs_repo: repos.prefs, 57 61 review_repo: repos.review, 58 62 social_repo: repos.social, 59 63 search_repo: repos.search, ··· 68 72 oauth_repo: Arc<dyn OAuthRepository>, 69 73 ) -> SharedState { 70 74 use crate::repository; 75 + 71 76 let review_repo = Arc::new(repository::review::mock::MockReviewRepository::new()) as Arc<dyn ReviewRepository>; 72 77 let social_repo = Arc::new(repository::social::mock::MockSocialRepository::new()) as Arc<dyn SocialRepository>; 73 78 let search_repo = Arc::new(repository::search::mock::MockSearchRepository::new()) as Arc<dyn SearchRepository>; 74 79 let deck_repo = Arc::new(repository::deck::mock::MockDeckRepository::new()) as Arc<dyn DeckRepository>; 75 80 let config = AppConfig { pds_url: "https://bsky.social".to_string() }; 81 + let prefs_repo = 82 + Arc::new(repository::preferences::mock::MockPreferencesRepository::new()) as Arc<dyn PreferencesRepository>; 76 83 77 84 let repos = Repositories { 78 85 card: card_repo, 79 86 note: note_repo, 80 87 oauth: oauth_repo, 88 + prefs: prefs_repo, 81 89 review: review_repo, 82 90 social: social_repo, 83 91 search: search_repo,
+80 -25
docs/core-user-journeys.md
··· 11 11 1. **Import**: User inputs a URL (Article) or pastes text. 12 12 2. **Generate**: System extracts metadata (and optionally snapshots content). 13 13 3. **Authoring**: 14 - * User highlights key sections in the source. 15 - * User creates **Notes** linked to highlights. 16 - * User generates **Cards** (Flashcards) from Notes or directly from source. 14 + - User highlights key sections in the source. 15 + - User creates **Notes** linked to highlights. 16 + - User generates **Cards** (Flashcards) from Notes or directly from source. 17 17 4. **Assembly**: User organizes Cards into a **Deck**. 18 18 5. **Publish**: User sets visibility (e.g., Public) and publishes the Deck. 19 19 6. **Result**: The Deck is now a shareable Artifact (ATProto record). ··· 74 74 1. **Session Start**: User opens the app/daily study mode. 75 75 2. **Review Queue**: System presents cards due for review based on SRS algorithm (e.g., SM-2). 76 76 3. **Interaction**: 77 - * User sees **Front** of card. 78 - * User attempts recall. 79 - * User reveals **Back**. 77 + - User sees **Front** of card. 78 + - User attempts recall. 79 + - User reveals **Back**. 80 80 4. **Grading**: User self-grades (e.g., 0-5). 81 81 5. **Update**: System schedules next review interval. 82 82 6. **Progress**: User sees feedback (cards done, streak incremented). 83 - * *Note: All grading/progress data is strictly private.* 83 + - *Note: All grading/progress data is strictly private.* 84 84 85 85 ### Detailed Flows 86 86 ··· 103 103 104 104 #### Progress Tracking 105 105 106 - * **Due count**: Cards needing review today 106 + - **Due count**: Cards needing review today 107 107 108 - * **Streak**: Consecutive days studied 109 - * **Reviewed today**: Cards completed this session 110 - * **Interval growth**: SM-2 algorithm increases intervals for mastered cards 108 + - **Streak**: Consecutive days studied 109 + - **Reviewed today**: Cards completed this session 110 + - **Interval growth**: SM-2 algorithm increases intervals for mastered cards 111 111 112 112 #### Keyboard Shortcuts 113 113 ··· 129 129 ### High-Level Workflow 130 130 131 131 1. **Discovery**: 132 - * User follows a Curator. 133 - * User sees a new Deck in their "New from Follows" feed. 132 + - User follows a Curator. 133 + - User sees a new Deck in their "New from Follows" feed. 134 134 2. **Acquisition**: User saves/pins the Deck to their library. 135 135 3. **Contribution (Forking)**: 136 - * User identifies a gap or error in the Deck. 137 - * User **Forks** the Deck. 138 - * User edits cards or adds new ones. 139 - * User republishes the modified Deck (referencing the original). 136 + - User identifies a gap or error in the Deck. 137 + - User **Forks** the Deck. 138 + - User edits cards or adds new ones. 139 + - User republishes the modified Deck (referencing the original). 140 140 4. **Loop**: Original author (or others) can see the fork and potentially merge changes (future scope) or users can switch to the better fork. 141 141 142 142 ## 4. Discussion & Moderation ··· 148 148 1. **Context**: A User is viewing a public Card or Deck. 149 149 2. **Discuss**: User adds a **Comment** (threaded) asking for clarification. 150 150 3. **Report** (Unhappy Path): 151 - * User encounters abusive content/spam. 152 - * User triggers **Report** flow. 153 - * Moderation system receives report. 154 - * Content may be hidden/labeled based on moderation actions. 151 + - User encounters abusive content/spam. 152 + - User triggers **Report** flow. 153 + - Moderation system receives report. 154 + - Content may be hidden/labeled based on moderation actions. 155 155 156 156 ## 5. Lecture Study Workflow 157 157 ··· 161 161 162 162 1. **Import**: User provides a Lecture URL (e.g., YouTube/Video). 163 163 2. **Structure**: 164 - * User creates an **Outline** of the lecture. 165 - * User adds **Timestamps** to segment the content. 164 + - User creates an **Outline** of the lecture. 165 + - User adds **Timestamps** to segment the content. 166 166 3. **Link**: 167 - * User creates Cards specific to timestamped segments. 168 - * Clicking context on a Card jumps video to the specific timestamp. 167 + - User creates Cards specific to timestamped segments. 168 + - Clicking context on a Card jumps video to the specific timestamp. 169 169 170 170 ## Authentication 171 171 ··· 179 179 180 180 1. Click avatar in header → "Logout" 181 181 2. → redirected to Landing page 182 + 183 + ## 6. Onboarding & Personalization 184 + 185 + **Goal**: New users get a personalized experience based on their learning goals. 186 + 187 + ### High-Level Workflow 188 + 189 + 1. **First Login**: User authenticates for the first time. 190 + 2. **Persona Selection**: User sees onboarding dialog with persona options: 191 + - **Learner**: Focus on studying existing content 192 + - **Creator**: Focus on building and sharing decks 193 + - **Curator**: Focus on discovering and organizing content 194 + 3. **Personalized Experience**: Empty states and tips adapt to chosen persona. 195 + 4. **Progress**: User preferences stored in backend for consistency across sessions. 196 + 197 + ### Detailed Flows 198 + 199 + #### First-Time Onboarding 200 + 201 + 1. User logs in successfully 202 + 2. System fetches preferences from `/api/preferences` 203 + 3. If `onboarding_completed_at` is null, show OnboardingDialog 204 + 4. User selects persona → Submit 205 + 5. Backend stores persona and marks onboarding complete 206 + 6. Dialog closes, user sees personalized empty states 207 + 208 + #### Persona-Aware Empty States 209 + 210 + - **Home (Library)**: Tips and actions tailored to persona 211 + - Learners: "Browse Discovery" and "Fork decks you like" 212 + - Creators: "Create New Deck" and "Import from Article" 213 + - Curators: "View Feed" and "Follow creators" 214 + 215 + - **Review**: First-timer guidance explaining SRS for users with no reviews 216 + 217 + ## 7. Help & Support 218 + 219 + **Goal**: Users can find answers to common questions. 220 + 221 + ### Detailed Flows 222 + 223 + #### Accessing Help 224 + 225 + 1. Footer → "Help" link, or navigate to `/help` 226 + 2. View FAQ organized by category: 227 + - Getting Started 228 + - Spaced Repetition 229 + - AT Protocol & Privacy 230 + - Community & Sharing 231 + 3. Click questions to expand accordion answers 232 + 233 + #### Beta Notice 234 + 235 + - Help page displays prominent notice that Malfestio is in active development 236 + - Links to Bluesky and GitHub for community support
+5 -34
docs/todo.md
··· 1 1 # Product + Technical Roadmap 2 2 3 - ## Protocol + Lexicon Strategy 4 - 5 - - "Artifacts" are publishable records (ATProto Lexicon). 6 - - "Learning state" is private (local DB + your backend sync; not public records). 7 - - Records are distributed and hard to migrate globally; keep mutable/private state out. 8 - - Lexicon evolution rules strongly encourage forward-compatible extensibility. 9 - 10 - ### Namespace + NSID conventions 11 - 12 - - `app.malfestio.note` 13 - - `app.malfestio.card` 14 - - `app.malfestio.deck` 15 - - `app.malfestio.source.article` 16 - - `app.malfestio.source.lecture` 17 - - `app.malfestio.collection` 18 - - `app.malfestio.thread.comment` 19 - 20 - ### Lexicon basics 21 - 22 - - Lexicon defines record types + XRPC endpoints; JSON-schema-like constraints. 23 - - Use "optional fields" heavily; avoid enums that will calcify the product too early. 24 - - Versioning: add fields, don't rename; never rely on being able to rewrite history. 25 - 26 - ### Schema boundaries (important) 27 - 28 - - **Public share layer**: 29 - - decks, cards, notes, collections, comments 30 - - **Private layer**: 31 - - review schedule, lapses, grades, per-card performance, streaks 32 - 33 - ### Auth direction 3 + ## Auth direction 34 4 35 5 - ATProto is moving toward OAuth for client↔PDS authorization. 36 6 - Plan for OAuth support even if MVP starts centralized. ··· 71 41 72 42 **App Vision Content:** 73 43 74 - - [ ] Onboarding flow with persona selection (Learner/Creator/Curator) 75 - - [ ] Empty states with helpful prompts for new users 44 + - [x] Onboarding flow with persona selection (Learner/Creator/Curator) 45 + - [x] Empty states with helpful prompts for new users 46 + - [x] Help center/FAQ section (with beta development notice) 76 47 - [ ] Tutorial/walkthrough for first deck creation 77 - - [ ] Help center or FAQ section -> Should mention that the app is still in development and subject to change. 78 48 79 49 **SEO & Meta:** 80 50 ··· 123 93 124 94 - [ ] OAuth login directly to user's PDS (vs. local-only auth) 125 95 - [ ] Handle resolution via DNS TXT or `/.well-known/atproto-did` 96 + - <https://malfestio.stormlightlabs.org> 126 97 - [ ] DPoP token binding for secure API calls 127 98 128 99 **Sync & Conflict Resolution:**
+30
lexicons/README.md
··· 2 2 3 3 This directory contains the Lexicon definitions for the malfestio's public records. 4 4 5 + ## Protocol + Lexicon Strategy 6 + 7 + - "Artifacts" are publishable records (ATProto Lexicon). 8 + - "Learning state" is private (local DB + your backend sync; not public records). 9 + - Records are distributed and hard to migrate globally; keep mutable/private state out. 10 + - Lexicon evolution rules strongly encourage forward-compatible extensibility. 11 + 12 + ### Namespace + NSID conventions 13 + 14 + - `app.malfestio.note` 15 + - `app.malfestio.card` 16 + - `app.malfestio.deck` 17 + - `app.malfestio.source.article` 18 + - `app.malfestio.source.lecture` 19 + - `app.malfestio.collection` 20 + - `app.malfestio.thread.comment` 21 + 22 + ### Lexicon basics 23 + 24 + - Lexicon defines record types + XRPC endpoints; JSON-schema-like constraints. 25 + - Use "optional fields" heavily; avoid enums that will calcify the product too early. 26 + - Versioning: add fields, don't rename; never rely on being able to rewrite history. 27 + 28 + ### Schema boundaries (important) 29 + 30 + - **Public share layer**: 31 + - decks, cards, notes, collections, comments 32 + - **Private layer**: 33 + - review schedule, lapses, grades, per-card performance, streaks 34 + 5 35 ## Evolution Rules 6 36 7 37 1. **Additive Changes Only**: You can add new optional fields to existing records.
+17
migrations/008_2025_12_30_user_prefs.sql
··· 1 + -- User preferences for onboarding and personalization 2 + -- Tracks onboarding completion and user persona selection 3 + 4 + CREATE TABLE user_prefs ( 5 + id UUID PRIMARY KEY, 6 + user_did TEXT NOT NULL UNIQUE, 7 + persona TEXT, -- 'learner' | 'creator' | 'curator' | NULL 8 + onboarding_completed_at TIMESTAMPTZ, 9 + tutorial_deck_completed BOOLEAN NOT NULL DEFAULT FALSE, 10 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 11 + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 12 + ); 13 + 14 + CREATE INDEX idx_user_prefs_did ON user_prefs(user_did); 15 + 16 + CREATE TRIGGER update_user_prefs_updated_at BEFORE UPDATE ON user_prefs 17 + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+26 -2
web/src/App.tsx
··· 1 1 import { AppLayout } from "$components/layout/AppLayout"; 2 - import { authStore } from "$lib/store"; 2 + import { OnboardingDialog } from "$components/OnboardingDialog"; 3 + import type { Persona } from "$lib/model"; 4 + import { authStore, preferencesStore } from "$lib/store"; 3 5 import About from "$pages/About"; 4 6 import DeckNew from "$pages/DeckNew"; 5 7 import DeckView from "$pages/DeckView"; 6 8 import Discovery from "$pages/Discovery"; 7 9 import Feed from "$pages/Feed"; 10 + import Help from "$pages/Help"; 8 11 import Home from "$pages/Home"; 9 12 import Import from "$pages/Import"; 10 13 import Landing from "$pages/Landing"; ··· 16 19 import Search from "$pages/Search"; 17 20 import { Route, Router } from "@solidjs/router"; 18 21 import type { Component } from "solid-js"; 19 - import { Show } from "solid-js"; 22 + import { createEffect, createSignal, onMount, Show } from "solid-js"; 20 23 21 24 const ProtectedRoute: Component<{ component: Component }> = (props) => { 25 + const [showOnboarding, setShowOnboarding] = createSignal(false); 26 + 27 + onMount(async () => { 28 + if (authStore.isAuthenticated()) { 29 + await preferencesStore.fetchPreferences(); 30 + } 31 + }); 32 + 33 + createEffect(() => { 34 + if (preferencesStore.needsOnboarding()) { 35 + setShowOnboarding(true); 36 + } 37 + }); 38 + 39 + const handleOnboardingComplete = (_persona: Persona) => { 40 + setShowOnboarding(false); 41 + preferencesStore.fetchPreferences(); 42 + }; 43 + 22 44 return ( 23 45 <Show when={authStore.isAuthenticated()} fallback={<Landing />}> 24 46 <AppLayout> 25 47 <props.component /> 26 48 </AppLayout> 49 + <OnboardingDialog open={showOnboarding()} onComplete={handleOnboardingComplete} /> 27 50 </Show> 28 51 ); 29 52 }; ··· 33 56 <Router> 34 57 <Route path="/login" component={Login} /> 35 58 <Route path="/about" component={About} /> 59 + <Route path="/help" component={Help} /> 36 60 <Route path="/" component={() => <ProtectedRoute component={Home} />} /> 37 61 <Route path="/decks" component={() => <ProtectedRoute component={Home} />} /> 38 62 <Route path="/decks/new" component={() => <ProtectedRoute component={DeckNew} />} />
+107
web/src/components/OnboardingDialog.tsx
··· 1 + import { Button } from "$components/ui/Button"; 2 + import { Dialog } from "$components/ui/Dialog"; 3 + import { api } from "$lib/api"; 4 + import type { Persona } from "$lib/model"; 5 + import { type Component, createSignal, For } from "solid-js"; 6 + import { Motion } from "solid-motionone"; 7 + 8 + type PersonaOption = { id: Persona; title: string; description: string; icon: string; action: string }; 9 + 10 + const personas: PersonaOption[] = [{ 11 + id: "learner", 12 + title: "Learner", 13 + description: "Study content created by others. Master new topics with spaced repetition.", 14 + icon: "i-bi-book", 15 + action: "Browse the Discovery page", 16 + }, { 17 + id: "creator", 18 + title: "Creator", 19 + description: "Build your own decks from articles, lectures, or scratch.", 20 + icon: "i-bi-pencil", 21 + action: "Create your first deck", 22 + }, { 23 + id: "curator", 24 + title: "Curator", 25 + description: "Discover, organize, and share the best learning content with others.", 26 + icon: "i-bi-collection", 27 + action: "Follow creators in your field", 28 + }]; 29 + 30 + type Props = { open: boolean; onComplete: (persona: Persona) => void }; 31 + 32 + export const OnboardingDialog: Component<Props> = (props) => { 33 + const [selected, setSelected] = createSignal<Persona | null>(null); 34 + const [submitting, setSubmitting] = createSignal(false); 35 + 36 + const handleConfirm = async () => { 37 + const persona = selected(); 38 + if (!persona) return; 39 + 40 + setSubmitting(true); 41 + try { 42 + await api.updatePreferences({ persona, complete_onboarding: true }); 43 + props.onComplete(persona); 44 + } catch (e) { 45 + console.error("Failed to save preferences:", e); 46 + } finally { 47 + setSubmitting(false); 48 + } 49 + }; 50 + 51 + return ( 52 + <Dialog 53 + open={props.open} 54 + onClose={() => {}} 55 + title="Welcome to Malfestio" 56 + actions={ 57 + <Button onClick={handleConfirm} disabled={!selected() || submitting()} class="w-full sm:w-auto"> 58 + {submitting() ? "Getting Started..." : "Get Started"} 59 + </Button> 60 + }> 61 + <div class="space-y-6"> 62 + <p class="text-[#C6C6C6] font-light"> 63 + How do you want to use Malfestio? Pick your primary focus — you can always do everything! 64 + </p> 65 + 66 + <div class="grid gap-3"> 67 + <For each={personas}> 68 + {(persona, i) => ( 69 + <Motion.button 70 + initial={{ opacity: 0, x: -10 }} 71 + animate={{ opacity: 1, x: 0 }} 72 + transition={{ duration: 0.3, delay: i() * 0.1 }} 73 + onClick={() => setSelected(persona.id)} 74 + class={`text-left p-4 rounded-lg border transition-all ${ 75 + selected() === persona.id 76 + ? "border-[#0F62FE] bg-[#0F62FE]/10" 77 + : "border-[#393939] bg-[#262626] hover:border-[#525252]" 78 + }`}> 79 + <div class="flex items-start gap-3"> 80 + <div class={`text-2xl mt-0.5 ${selected() === persona.id ? "text-[#0F62FE]" : "text-[#8D8D8D]"}`}> 81 + <span class={persona.icon} /> 82 + </div> 83 + <div class="flex-1"> 84 + <div class="flex items-center gap-2 mb-1"> 85 + <h3 class={`font-medium ${selected() === persona.id ? "text-[#F4F4F4]" : "text-[#C6C6C6]"}`}> 86 + {persona.title} 87 + </h3> 88 + {selected() === persona.id && <span class="i-bi-check-circle-fill text-[#0F62FE] text-sm" />} 89 + </div> 90 + <p class="text-sm text-[#8D8D8D] mb-2">{persona.description}</p> 91 + <p class="text-xs text-[#525252]"> 92 + <span class="text-[#0F62FE]">→</span> {persona.action} 93 + </p> 94 + </div> 95 + </div> 96 + </Motion.button> 97 + )} 98 + </For> 99 + </div> 100 + 101 + <p class="text-xs text-[#525252] text-center">You can change this anytime in Settings.</p> 102 + </div> 103 + </Dialog> 104 + ); 105 + }; 106 + 107 + export default OnboardingDialog;
+1
web/src/components/layout/Footer.tsx
··· 24 24 </p> 25 25 <div class="flex items-center gap-6"> 26 26 <A href="/about" class="hover:text-[#F4F4F4] transition-colors">About</A> 27 + <A href="/help" class="hover:text-[#F4F4F4] transition-colors">Help</A> 27 28 <a 28 29 href="https://tangled.org/desertthunder.dev/malfestio" 29 30 target="_blank"
+95
web/src/components/tests/OnboardingDialog.test.tsx
··· 1 + import { cleanup, fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 2 + import { afterEach, describe, expect, it, vi } from "vitest"; 3 + import { OnboardingDialog } from "../OnboardingDialog"; 4 + 5 + vi.mock("$lib/api", () => ({ api: { updatePreferences: vi.fn() } })); 6 + 7 + describe("OnboardingDialog", () => { 8 + afterEach(() => { 9 + cleanup(); 10 + vi.clearAllMocks(); 11 + }); 12 + 13 + it("renders when open", () => { 14 + render(() => <OnboardingDialog open={true} onComplete={() => {}} />); 15 + expect(screen.getByText("Welcome to Malfestio")).toBeInTheDocument(); 16 + }); 17 + 18 + it("does not render when closed", () => { 19 + render(() => <OnboardingDialog open={false} onComplete={() => {}} />); 20 + expect(screen.queryByText("Welcome to Malfestio")).not.toBeInTheDocument(); 21 + }); 22 + 23 + it("displays all three persona options", () => { 24 + render(() => <OnboardingDialog open={true} onComplete={() => {}} />); 25 + expect(screen.getByText("Learner")).toBeInTheDocument(); 26 + expect(screen.getByText("Creator")).toBeInTheDocument(); 27 + expect(screen.getByText("Curator")).toBeInTheDocument(); 28 + }); 29 + 30 + it("shows persona descriptions", () => { 31 + render(() => <OnboardingDialog open={true} onComplete={() => {}} />); 32 + expect(screen.getByText(/Study content created by others/i)).toBeInTheDocument(); 33 + expect(screen.getByText(/Build your own decks/i)).toBeInTheDocument(); 34 + expect(screen.getByText(/Discover, organize, and share/i)).toBeInTheDocument(); 35 + }); 36 + 37 + it("Get Started button is disabled until a persona is selected", () => { 38 + render(() => <OnboardingDialog open={true} onComplete={() => {}} />); 39 + const button = screen.getByRole("button", { name: /Get Started/i }); 40 + expect(button).toBeDisabled(); 41 + }); 42 + 43 + it("enables Get Started button after selecting persona", async () => { 44 + render(() => <OnboardingDialog open={true} onComplete={() => {}} />); 45 + 46 + const learnerOption = screen.getByText("Learner").closest("button"); 47 + fireEvent.click(learnerOption!); 48 + 49 + const button = screen.getByRole("button", { name: /Get Started/i }); 50 + expect(button).not.toBeDisabled(); 51 + }); 52 + 53 + it("calls updatePreferences and onComplete when submitting", async () => { 54 + const { api } = await import("$lib/api"); 55 + vi.mocked(api.updatePreferences).mockResolvedValue( 56 + { 57 + ok: true, 58 + json: () => Promise.resolve({ persona: "creator", onboarding_completed_at: "2024-01-01" }), 59 + } as unknown as Response, 60 + ); 61 + 62 + const onComplete = vi.fn(); 63 + render(() => <OnboardingDialog open={true} onComplete={onComplete} />); 64 + 65 + const creatorOption = screen.getByText("Creator").closest("button"); 66 + fireEvent.click(creatorOption!); 67 + 68 + const submitButton = screen.getByRole("button", { name: /Get Started/i }); 69 + fireEvent.click(submitButton); 70 + 71 + await waitFor(() => { 72 + expect(api.updatePreferences).toHaveBeenCalledWith({ persona: "creator", complete_onboarding: true }); 73 + expect(onComplete).toHaveBeenCalledWith("creator"); 74 + }); 75 + }); 76 + 77 + it("shows submitting state", async () => { 78 + const { api } = await import("$lib/api"); 79 + vi.mocked(api.updatePreferences).mockImplementation(() => 80 + new Promise((resolve) => 81 + setTimeout(() => resolve({ ok: true, json: () => Promise.resolve({}) } as unknown as Response), 100) 82 + ) 83 + ); 84 + 85 + render(() => <OnboardingDialog open={true} onComplete={() => {}} />); 86 + 87 + const learnerOption = screen.getByText("Learner").closest("button"); 88 + fireEvent.click(learnerOption!); 89 + 90 + const submitButton = screen.getByRole("button", { name: /Get Started/i }); 91 + fireEvent.click(submitButton); 92 + 93 + expect(screen.getByText("Getting Started...")).toBeInTheDocument(); 94 + }); 95 + });
+4
web/src/lib/api.ts
··· 40 40 getDecks: () => apiFetch("/decks", { method: "GET" }), 41 41 getDeck: (id: string) => apiFetch(`/decks/${id}`, { method: "GET" }), 42 42 getDeckCards: (id: string) => apiFetch(`/decks/${id}/cards`, { method: "GET" }), 43 + getPreferences: () => apiFetch("/preferences", { method: "GET" }), 43 44 getDiscovery: () => apiFetch("/discovery", { method: "GET" }), 44 45 createDeck: async (payload: CreateDeckPayload) => { 45 46 const { cards, ...deckPayload } = payload; ··· 75 76 }, 76 77 submitReview: (cardId: string, grade: number) => { 77 78 return apiFetch("/review/submit", { method: "POST", body: JSON.stringify({ card_id: cardId, grade }) }); 79 + }, 80 + updatePreferences: (updates: import("./model").UpdatePreferencesPayload) => { 81 + return apiFetch("/preferences", { method: "PUT", body: JSON.stringify(updates) }); 78 82 }, 79 83 };
+15
web/src/lib/model.ts
··· 89 89 export const asDeck = (r: SearchResult) => (r.item_type === "deck" ? r : undefined); 90 90 export const asCard = (r: SearchResult) => (r.item_type === "card" ? r : undefined); 91 91 export const asNote = (r: SearchResult) => (r.item_type === "note" ? r : undefined); 92 + 93 + export type Persona = "learner" | "creator" | "curator"; 94 + 95 + export type UserPreferences = { 96 + user_did: string; 97 + persona: Persona | null; 98 + onboarding_completed_at: string | null; 99 + tutorial_deck_completed: boolean; 100 + }; 101 + 102 + export type UpdatePreferencesPayload = { 103 + persona?: Persona; 104 + complete_onboarding?: boolean; 105 + tutorial_deck_completed?: boolean; 106 + };
+44 -1
web/src/lib/store.ts
··· 1 1 import { createRoot, createSignal } from "solid-js"; 2 - import type { User } from "./model"; 2 + import { api } from "./api"; 3 + import type { Persona, User, UserPreferences } from "./model"; 3 4 4 5 export type AuthState = { 5 6 user: User | null; ··· 39 40 } 40 41 41 42 export const authStore = createRoot(createAuthStore); 43 + 44 + function createPreferencesStore() { 45 + const [preferences, setPreferences] = createSignal<UserPreferences | null>(null); 46 + const [loading, setLoading] = createSignal(false); 47 + 48 + const fetchPreferences = async () => { 49 + if (!authStore.isAuthenticated()) return; 50 + setLoading(true); 51 + try { 52 + const res = await api.getPreferences(); 53 + if (res.ok) { 54 + setPreferences(await res.json()); 55 + } 56 + } catch (e) { 57 + console.error("Failed to fetch preferences:", e); 58 + } finally { 59 + setLoading(false); 60 + } 61 + }; 62 + 63 + const updatePreferences = async (updates: { persona?: Persona; complete_onboarding?: boolean }) => { 64 + try { 65 + const res = await api.updatePreferences(updates); 66 + if (res.ok) { 67 + setPreferences(await res.json()); 68 + } 69 + } catch (e) { 70 + console.error("Failed to update preferences:", e); 71 + } 72 + }; 73 + 74 + const needsOnboarding = () => { 75 + const prefs = preferences(); 76 + return prefs !== null && prefs.onboarding_completed_at === null; 77 + }; 78 + 79 + const persona = () => preferences()?.persona ?? null; 80 + 81 + return { preferences, loading, fetchPreferences, updatePreferences, needsOnboarding, persona }; 82 + } 83 + 84 + export const preferencesStore = createRoot(createPreferencesStore);
+41 -1
web/src/lib/tests/model.test.ts
··· 1 1 import { describe, expect, it } from "vitest"; 2 - import { asCard, asDeck, asNote, type SearchResult } from "../model"; 2 + import { asCard, asDeck, asNote, type Persona, type SearchResult, type UserPreferences } from "../model"; 3 3 4 4 describe("Type Guards", () => { 5 5 const deckResult: SearchResult = { ··· 51 51 expect(asNote(cardResult)).toBeUndefined(); 52 52 }); 53 53 }); 54 + 55 + describe("Persona Types", () => { 56 + it("accepts valid persona values", () => { 57 + const learner: Persona = "learner"; 58 + const creator: Persona = "creator"; 59 + const curator: Persona = "curator"; 60 + 61 + expect(learner).toBe("learner"); 62 + expect(creator).toBe("creator"); 63 + expect(curator).toBe("curator"); 64 + }); 65 + }); 66 + 67 + describe("UserPreferences Types", () => { 68 + it("accepts valid user preferences object", () => { 69 + const prefs: UserPreferences = { 70 + user_did: "did:plc:test", 71 + persona: "learner", 72 + onboarding_completed_at: "2024-01-01T00:00:00Z", 73 + tutorial_deck_completed: false, 74 + }; 75 + 76 + expect(prefs.user_did).toBe("did:plc:test"); 77 + expect(prefs.persona).toBe("learner"); 78 + expect(prefs.onboarding_completed_at).toBe("2024-01-01T00:00:00Z"); 79 + expect(prefs.tutorial_deck_completed).toBe(false); 80 + }); 81 + 82 + it("accepts null values for optional fields", () => { 83 + const prefs: UserPreferences = { 84 + user_did: "did:plc:test", 85 + persona: null, 86 + onboarding_completed_at: null, 87 + tutorial_deck_completed: false, 88 + }; 89 + 90 + expect(prefs.persona).toBeNull(); 91 + expect(prefs.onboarding_completed_at).toBeNull(); 92 + }); 93 + });
+206
web/src/pages/Help.tsx
··· 1 + import { Footer } from "$components/layout/Footer"; 2 + import { Button } from "$components/ui/Button"; 3 + import { A } from "@solidjs/router"; 4 + import type { Component, JSX } from "solid-js"; 5 + import { createSignal, For, Show } from "solid-js"; 6 + import { Motion } from "solid-motionone"; 7 + 8 + type FAQItem = { question: string; answer: JSX.Element | string }; 9 + 10 + type FAQSection = { title: string; icon: string; items: FAQItem[] }; 11 + 12 + const faqSections: FAQSection[] = [{ 13 + title: "Getting Started", 14 + icon: "i-bi-rocket-takeoff", 15 + items: [{ 16 + question: "What is Malfestio?", 17 + answer: 18 + "Malfestio is a decentralized learning platform that combines flashcards with spaced repetition. Built on the AT Protocol, your content is portable and you maintain ownership of your data.", 19 + }, { 20 + question: "How do I create my first deck?", 21 + answer: 22 + "Click 'Create Deck' in your Library. Add a title, description, and tags, then add cards with questions and answers. You can also import content from articles or lectures.", 23 + }, { 24 + question: "What makes Malfestio different from other flashcard apps?", 25 + answer: 26 + "Malfestio is built on the AT Protocol, meaning your content is decentralized and portable. You can fork and remix others' decks, follow creators, and participate in a community-driven learning ecosystem.", 27 + }], 28 + }, { 29 + title: "Spaced Repetition", 30 + icon: "i-bi-arrow-repeat", 31 + items: [{ 32 + question: "What is spaced repetition?", 33 + answer: 34 + "Spaced repetition is a learning technique that schedules reviews at optimal intervals. Cards you struggle with appear more often; cards you know well appear less frequently.", 35 + }, { 36 + question: "How does the grading system work?", 37 + answer: ( 38 + <ul class="list-disc list-inside space-y-1"> 39 + <li> 40 + <strong>1 (Again)</strong>: Completely forgot — will review soon 41 + </li> 42 + <li> 43 + <strong>2 (Hard)</strong>: Struggled to remember 44 + </li> 45 + <li> 46 + <strong>3 (Good)</strong>: Remembered with some effort 47 + </li> 48 + <li> 49 + <strong>4 (Easy)</strong>: Remembered easily 50 + </li> 51 + <li> 52 + <strong>5 (Perfect)</strong>: Instant recall 53 + </li> 54 + </ul> 55 + ), 56 + }, { 57 + question: "How are review intervals calculated?", 58 + answer: 59 + "We use the SM-2 algorithm, a proven spaced repetition method. Intervals grow exponentially for cards you know well, typically starting at 1 day and growing to weeks or months.", 60 + }], 61 + }, { 62 + title: "AT Protocol & Privacy", 63 + icon: "i-bi-globe", 64 + items: [{ 65 + question: "What is the AT Protocol?", 66 + answer: 67 + "The AT Protocol (Authenticated Transfer Protocol) is an open, decentralized social networking protocol. It powers Bluesky and enables portable, user-owned data.", 68 + }, { 69 + question: "Is my study data private?", 70 + answer: 71 + "Yes! Your review history, grades, and learning progress are stored locally and never published to the network. Only content you explicitly choose to publish (decks, cards) becomes public.", 72 + }, { 73 + question: "Can I use my existing Bluesky account?", 74 + answer: 75 + "Yes! You can log in with your Bluesky handle and app password. Your decks can be published to your AT Protocol repository.", 76 + }], 77 + }, { 78 + title: "Community & Sharing", 79 + icon: "i-bi-people", 80 + items: [{ 81 + question: "What does 'Fork' mean?", 82 + answer: 83 + "Forking creates a personal copy of someone else's deck. You can study, edit, and improve it. The original deck remains unchanged.", 84 + }, { 85 + question: "How do I discover new decks?", 86 + answer: 87 + "Use the Discovery page to browse trending decks and popular tags. You can also follow creators and see their latest decks in your feed.", 88 + }, { 89 + question: "Can I make my decks private?", 90 + answer: 91 + "Yes! Each deck has visibility settings: Private (only you), Unlisted (anyone with link), Public (discoverable by all), or Shared With (specific users).", 92 + }], 93 + }]; 94 + 95 + const AccordionItem: Component<{ item: FAQItem; index: number }> = (props) => { 96 + const [open, setOpen] = createSignal(false); 97 + 98 + return ( 99 + <Motion.div 100 + initial={{ opacity: 0, y: 10 }} 101 + animate={{ opacity: 1, y: 0 }} 102 + transition={{ duration: 0.2, delay: props.index * 0.05 }} 103 + class="border-b border-[#393939] last:border-b-0"> 104 + <button onClick={() => setOpen(!open())} class="w-full py-4 flex items-center justify-between text-left group"> 105 + <span class="text-[#F4F4F4] group-hover:text-[#0F62FE] transition-colors font-medium"> 106 + {props.item.question} 107 + </span> 108 + <span class={`i-bi-chevron-down text-[#8D8D8D] transition-transform ${open() ? "rotate-180" : ""}`} /> 109 + </button> 110 + <Show when={open()}> 111 + <Motion.div 112 + initial={{ opacity: 0, height: 0 }} 113 + animate={{ opacity: 1, height: "auto" }} 114 + transition={{ duration: 0.2 }} 115 + class="pb-4 text-[#C6C6C6] font-light leading-relaxed"> 116 + {props.item.answer} 117 + </Motion.div> 118 + </Show> 119 + </Motion.div> 120 + ); 121 + }; 122 + 123 + const Help: Component = () => { 124 + return ( 125 + <div class="min-h-screen bg-[#161616] flex flex-col"> 126 + <header class="border-b border-[#262626] bg-[#161616]/95 backdrop-blur sticky top-0 z-50"> 127 + <div class="max-w-4xl mx-auto px-6 py-4 flex items-center justify-between"> 128 + <A href="/" class="text-xl font-medium text-[#F4F4F4] hover:text-[#0F62FE] transition-colors">Malfestio</A> 129 + <A href="/"> 130 + <Button variant="secondary" size="sm">Back to App</Button> 131 + </A> 132 + </div> 133 + </header> 134 + 135 + <div class="bg-[#0F62FE]/10 border-b border-[#0F62FE]/30"> 136 + <div class="max-w-4xl mx-auto px-6 py-3 flex items-center gap-3"> 137 + <span class="i-bi-info-circle text-[#0F62FE]" /> 138 + <p class="text-sm text-[#C6C6C6]"> 139 + <strong class="text-[#F4F4F4]">Beta Notice:</strong>{" "} 140 + Malfestio is still in active development. Features may change and some functionality may be incomplete. 141 + </p> 142 + </div> 143 + </div> 144 + 145 + <main class="flex-1 max-w-4xl mx-auto px-6 py-12 w-full"> 146 + <Motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.4 }}> 147 + <h1 class="text-4xl font-light text-[#F4F4F4] mb-2">Help Center</h1> 148 + <p class="text-[#C6C6C6] mb-12 font-light">Find answers to common questions about using Malfestio.</p> 149 + </Motion.div> 150 + 151 + <div class="space-y-12"> 152 + <For each={faqSections}> 153 + {(section, sectionIndex) => ( 154 + <Motion.section 155 + initial={{ opacity: 0, y: 20 }} 156 + animate={{ opacity: 1, y: 0 }} 157 + transition={{ duration: 0.4, delay: sectionIndex() * 0.1 }}> 158 + <div class="flex items-center gap-3 mb-6"> 159 + <span class={`${section.icon} text-2xl text-[#0F62FE]`} /> 160 + <h2 class="text-xl font-medium text-[#F4F4F4]">{section.title}</h2> 161 + </div> 162 + <div class="bg-[#1E1E1E] rounded-lg border border-[#262626] px-6"> 163 + <For each={section.items}>{(item, i) => <AccordionItem item={item} index={i()} />}</For> 164 + </div> 165 + </Motion.section> 166 + )} 167 + </For> 168 + </div> 169 + 170 + <Motion.div 171 + initial={{ opacity: 0 }} 172 + animate={{ opacity: 1 }} 173 + transition={{ duration: 0.4, delay: 0.5 }} 174 + class="mt-16 text-center py-12 border-t border-[#262626]"> 175 + <h3 class="text-lg font-medium text-[#F4F4F4] mb-2">Still have questions?</h3> 176 + <p class="text-[#C6C6C6] font-light mb-6">We're here to help. Reach out on Bluesky or check our GitHub.</p> 177 + <div class="flex gap-4 justify-center"> 178 + <a 179 + href="https://bsky.app" 180 + target="_blank" 181 + rel="noopener noreferrer" 182 + class="inline-flex items-center gap-2 text-[#0F62FE] hover:underline"> 183 + <span class="i-bi-chat" /> Bluesky 184 + </a> 185 + <a 186 + href="https://github.com" 187 + target="_blank" 188 + rel="noopener noreferrer" 189 + class="inline-flex items-center gap-2 text-[#0F62FE] hover:underline"> 190 + <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 25 25" class="inline"> 191 + <path 192 + fill="currentColor" 193 + d="M12.5.75C6.146.75 1 5.896 1 12.25c0 5.089 3.292 9.387 7.863 10.91.575.101.79-.244.79-.546 0-.273-.014-1.178-.014-2.142-2.889.532-3.636-.705-3.866-1.35-.13-.331-.69-1.352-1.18-1.625-.402-.216-.977-.748-.014-.762.906-.014 1.553.834 1.769 1.179 1.035 1.74 2.688 1.25 3.349.948.1-.747.402-1.25.733-1.538-2.559-.287-5.232-1.279-5.232-5.678 0-1.25.446-2.285 1.18-3.09-.115-.288-.517-1.467.115-3.048 0 0 .963-.302 3.163 1.179.92-.259 1.897-.388 2.875-.388.977 0 1.955.13 2.875.388 2.2-1.495 3.162-1.179 3.162-1.179.633 1.581.23 2.76.115 3.048.733.805 1.179 1.825 1.179 3.09 0 4.413-2.688 5.39-5.247 5.678.417.36.776 1.05.776 2.128 0 1.538-.014 2.774-.014 3.162 0 .302.216.662.79.547C20.709 21.637 24 17.324 24 12.25 24 5.896 18.854.75 12.5.75Z" /> 194 + </svg> 195 + GitHub 196 + </a> 197 + </div> 198 + </Motion.div> 199 + </main> 200 + 201 + <Footer /> 202 + </div> 203 + ); 204 + }; 205 + 206 + export default Help;
+105 -11
web/src/pages/Home.tsx
··· 3 3 import { Skeleton } from "$components/ui/Skeleton"; 4 4 import { Tag } from "$components/ui/Tag"; 5 5 import { api } from "$lib/api"; 6 - import type { Deck } from "$lib/model"; 6 + import type { Deck, Persona } from "$lib/model"; 7 + import { preferencesStore } from "$lib/store"; 7 8 import { Button } from "$ui/Button"; 8 9 import { A } from "@solidjs/router"; 9 - import type { Component } from "solid-js"; 10 - import { createResource, For, Index, Show } from "solid-js"; 10 + import type { Component, JSX } from "solid-js"; 11 + import { createMemo, createResource, For, Index, Show } from "solid-js"; 11 12 import { Motion } from "solid-motionone"; 12 13 13 14 const DeckCard: Component<{ deck: Deck; index: number }> = (props) => ( ··· 60 61 </Card> 61 62 ); 62 63 64 + type PersonaTip = { title: string; description: string; icon: JSX.Element; action: JSX.Element; tips: string[] }; 65 + 66 + const personaTips: Record<Persona, PersonaTip> = { 67 + learner: { 68 + title: "Ready to start learning?", 69 + description: "Find decks from the community or create your own study materials.", 70 + icon: <span class="i-bi-book text-4xl text-[#0F62FE]" />, 71 + action: ( 72 + <div class="flex gap-3 flex-wrap justify-center"> 73 + <A href="/discovery"> 74 + <Button variant="secondary">Browse Discovery</Button> 75 + </A> 76 + <A href="/decks/new"> 77 + <Button>Create First Deck</Button> 78 + </A> 79 + </div> 80 + ), 81 + tips: [ 82 + "Start by exploring public decks in Discovery", 83 + "Fork decks you like to customize them", 84 + "Review cards daily for best retention", 85 + ], 86 + }, 87 + creator: { 88 + title: "Create your first deck!", 89 + description: "Share your knowledge with the community through flashcards.", 90 + icon: <span class="i-bi-pencil text-4xl text-[#0F62FE]" />, 91 + action: ( 92 + <div class="flex gap-3 flex-wrap justify-center"> 93 + <A href="/decks/new"> 94 + <Button>Create New Deck</Button> 95 + </A> 96 + <A href="/import"> 97 + <Button variant="secondary">Import from Article</Button> 98 + </A> 99 + </div> 100 + ), 101 + tips: [ 102 + "Import articles to auto-generate flashcards", 103 + "Use cloze deletions for key terms", 104 + "Add hints to help learners remember", 105 + ], 106 + }, 107 + curator: { 108 + title: "Build your collection", 109 + description: "Discover and organize the best learning content for others.", 110 + icon: <span class="i-bi-collection text-4xl text-[#0F62FE]" />, 111 + action: ( 112 + <div class="flex gap-3 flex-wrap justify-center"> 113 + <A href="/feed"> 114 + <Button variant="secondary">View Feed</Button> 115 + </A> 116 + <A href="/discovery"> 117 + <Button>Explore Discovery</Button> 118 + </A> 119 + </div> 120 + ), 121 + tips: [ 122 + "Follow creators whose content you enjoy", 123 + "Fork and improve existing decks", 124 + "Use tags to organize by topic", 125 + ], 126 + }, 127 + }; 128 + 129 + const defaultTip: PersonaTip = { 130 + title: "No decks found", 131 + description: "Create your first deck to get started with spaced repetition learning.", 132 + icon: <span class="i-bi-collection text-4xl text-[#525252]" />, 133 + action: ( 134 + <A href="/decks/new"> 135 + <Button>Create Your First Deck</Button> 136 + </A> 137 + ), 138 + tips: [], 139 + }; 140 + 63 141 const Home: Component = () => { 64 142 const [decks] = createResource(async () => { 65 143 const res = await api.getDecks(); 66 144 return res.ok ? ((await res.json()) as Deck[]) : []; 67 145 }); 68 146 147 + const currentTip = createMemo(() => { 148 + const persona = preferencesStore.persona(); 149 + return persona ? personaTips[persona] : defaultTip; 150 + }); 151 + 69 152 return ( 70 153 <Motion.div 71 154 initial={{ opacity: 0 }} ··· 95 178 fallback={ 96 179 <div class="col-span-full"> 97 180 <EmptyState 98 - title="No decks found" 99 - description="Create your first deck to get started with spaced repetition learning." 100 - icon={<span class="i-bi-collection text-4xl text-[#525252]" />} 101 - action={ 102 - <A href="/decks/new"> 103 - <Button>Create Your First Deck</Button> 104 - </A> 105 - } /> 181 + title={currentTip().title} 182 + description={currentTip().description} 183 + icon={currentTip().icon} 184 + action={currentTip().action} /> 185 + <Show when={currentTip().tips.length > 0}> 186 + <div class="mt-8 p-6 bg-[#1E1E1E] rounded-lg border border-[#262626] max-w-lg mx-auto"> 187 + <h4 class="text-sm font-medium text-[#F4F4F4] mb-3">Quick Tips</h4> 188 + <ul class="space-y-2"> 189 + <For each={currentTip().tips}> 190 + {(tip) => ( 191 + <li class="flex items-start gap-2 text-sm text-[#C6C6C6]"> 192 + <span class="i-bi-lightbulb text-[#0F62FE] mt-0.5 shrink-0" /> 193 + {tip} 194 + </li> 195 + )} 196 + </For> 197 + </ul> 198 + </div> 199 + </Show> 106 200 </div> 107 201 }> 108 202 {(deck, i) => <DeckCard deck={deck} index={i()} />}
+62 -40
web/src/pages/Review.tsx
··· 9 9 import { type Component, createSignal, onMount, Show } from "solid-js"; 10 10 import { Motion } from "solid-motionone"; 11 11 12 + const AllCaughtUp: Component<{ stats: StudyStatsType | null }> = (props) => ( 13 + <> 14 + <p class="text-4xl mb-4 flex items-center gap-2 justify-center"> 15 + <i class="i-bi-star-fill text-yellow-400" /> 16 + </p> 17 + <h2 class="text-xl font-semibold text-white mb-2">All Caught Up!</h2> 18 + <p class="text-gray-400 mb-6">You have no cards due for review right now.</p> 19 + <Show when={props.stats?.total_reviews === 0}> 20 + <div class="my-6 p-4 bg-blue-900/20 rounded-lg border border-blue-800/30 text-left max-w-md mx-auto"> 21 + <h4 class="text-sm font-medium text-blue-400 mb-2 flex items-center gap-2"> 22 + <span class="i-bi-info-circle" /> New to Spaced Repetition? 23 + </h4> 24 + <p class="text-sm text-gray-400 mb-3"> 25 + Cards appear for review based on how well you remember them. The better you know a card, the longer until you 26 + see it again. 27 + </p> 28 + <p class="text-sm text-gray-400">Add cards to a deck, and they'll show up here when they're due for review!</p> 29 + </div> 30 + </Show> 31 + </> 32 + ); 33 + 34 + const SessionComplete: Component = () => ( 35 + <> 36 + <p class="text-4xl mb-4 flex items-center gap-2 justify-center"> 37 + <i class="i-bi-trophy-fill text-yellow-400" /> 38 + </p> 39 + <h2 class="text-xl font-semibold text-white mb-2">Session Complete!</h2> 40 + <p class="text-gray-400 mb-6">Great job! You've reviewed all your due cards.</p> 41 + </> 42 + ); 43 + 44 + const KbShortcuts: Component = () => ( 45 + <Motion.div {...fadeIn} class="mt-8 bg-gray-900/50 rounded-xl p-6 border border-gray-800/50"> 46 + <h3 class="text-sm font-semibold text-gray-400 mb-4">Keyboard Shortcuts</h3> 47 + <div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm"> 48 + <div class="flex items-center gap-2"> 49 + <kbd class="px-2 py-1 bg-gray-800 rounded text-gray-300">Space</kbd> 50 + <span class="text-gray-400">Flip card</span> 51 + </div> 52 + <div class="flex items-center gap-2"> 53 + <kbd class="px-2 py-1 bg-gray-800 rounded text-gray-300">1-5</kbd> 54 + <span class="text-gray-400">Grade answer</span> 55 + </div> 56 + <div class="flex items-center gap-2"> 57 + <kbd class="px-2 py-1 bg-gray-800 rounded text-gray-300">E</kbd> 58 + <span class="text-gray-400">Edit card</span> 59 + </div> 60 + <div class="flex items-center gap-2"> 61 + <kbd class="px-2 py-1 bg-gray-800 rounded text-gray-300">Esc</kbd> 62 + <span class="text-gray-400">Exit session</span> 63 + </div> 64 + </div> 65 + </Motion.div> 66 + ); 67 + 12 68 const Review: Component = () => { 13 69 const params = useParams<{ deckId?: string }>(); 14 70 const navigate = useNavigate(); ··· 50 106 when={!sessionActive()} 51 107 fallback={<StudySession cards={cards()} onComplete={handleComplete} onExit={handleExit} />}> 52 108 <Motion.div {...fadeIn} class="max-w-4xl mx-auto py-8 px-4"> 53 - <h1 class="text-3xl font-bold text-white mb-8">{params.deckId ? "Deck Review" : "Daily Review"}</h1> 54 - 109 + <h1 class="text-3xl font-bold text-white mb-8"> 110 + <Show when={params.deckId} fallback="Daily Review">Deck Review</Show> 111 + </h1> 55 112 <ReviewStats stats={stats()} loading={loading()} /> 56 - 57 113 <div class="mt-8 bg-gray-900 rounded-xl p-6 border border-gray-800"> 58 114 <Show 59 115 when={!loading()} ··· 67 123 when={cards().length > 0} 68 124 fallback={ 69 125 <div class="text-center py-8"> 70 - <p class="text-4xl mb-4 flex items-center gap-2"> 71 - <i class="i-bi-star-fill text-yellow-400" /> 72 - </p> 73 - <Show 74 - when={sessionComplete()} 75 - fallback={ 76 - <> 77 - <h2 class="text-xl font-semibold text-white mb-2">All Caught Up!</h2> 78 - <p class="text-gray-400 mb-6">You have no cards due for review right now.</p> 79 - </> 80 - }> 81 - <> 82 - <h2 class="text-xl font-semibold text-white mb-2">Session Complete!</h2> 83 - <p class="text-gray-400 mb-6">Great job! You've reviewed all your due cards.</p> 84 - </> 126 + <Show when={sessionComplete()} fallback={<AllCaughtUp stats={stats()} />}> 127 + <SessionComplete /> 85 128 </Show> 86 129 <Button onClick={() => navigate("/")} variant="secondary">Back to Library</Button> 87 130 </div> ··· 96 139 </Show> 97 140 </Show> 98 141 </div> 99 - 100 - <Motion.div {...fadeIn} class="mt-8 bg-gray-900/50 rounded-xl p-6 border border-gray-800/50"> 101 - <h3 class="text-sm font-semibold text-gray-400 mb-4">Keyboard Shortcuts</h3> 102 - <div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm"> 103 - <div class="flex items-center gap-2"> 104 - <kbd class="px-2 py-1 bg-gray-800 rounded text-gray-300">Space</kbd> 105 - <span class="text-gray-400">Flip 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">1-5</kbd> 109 - <span class="text-gray-400">Grade answer</span> 110 - </div> 111 - <div class="flex items-center gap-2"> 112 - <kbd class="px-2 py-1 bg-gray-800 rounded text-gray-300">E</kbd> 113 - <span class="text-gray-400">Edit card</span> 114 - </div> 115 - <div class="flex items-center gap-2"> 116 - <kbd class="px-2 py-1 bg-gray-800 rounded text-gray-300">Esc</kbd> 117 - <span class="text-gray-400">Exit session</span> 118 - </div> 119 - </div> 120 - </Motion.div> 142 + <KbShortcuts /> 121 143 </Motion.div> 122 144 </Show> 123 145 );
+86
web/src/pages/tests/Help.test.tsx
··· 1 + import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library"; 2 + import { JSX } from "solid-js"; 3 + import { afterEach, describe, expect, it, vi } from "vitest"; 4 + import Help from "../Help"; 5 + 6 + vi.mock( 7 + "@solidjs/router", 8 + () => ({ 9 + A: (props: { href: string; children: JSX.Element; class?: string }) => ( 10 + <a href={props.href} class={props.class}>{props.children}</a> 11 + ), 12 + }), 13 + ); 14 + 15 + vi.mock("$components/layout/Footer", () => ({ Footer: () => <footer data-testid="footer">Footer</footer> })); 16 + 17 + describe("Help Page", () => { 18 + afterEach(cleanup); 19 + 20 + it("renders the help page header", () => { 21 + render(() => <Help />); 22 + expect(screen.getByText("Help Center")).toBeInTheDocument(); 23 + }); 24 + 25 + it("displays beta notice", () => { 26 + render(() => <Help />); 27 + expect(screen.getByText("Beta Notice:")).toBeInTheDocument(); 28 + expect(screen.getByText(/Malfestio is still in active development/i)).toBeInTheDocument(); 29 + }); 30 + 31 + it("shows all FAQ categories", () => { 32 + render(() => <Help />); 33 + expect(screen.getByText("Getting Started")).toBeInTheDocument(); 34 + expect(screen.getByText("Spaced Repetition")).toBeInTheDocument(); 35 + expect(screen.getByText("AT Protocol & Privacy")).toBeInTheDocument(); 36 + expect(screen.getByText("Community & Sharing")).toBeInTheDocument(); 37 + }); 38 + 39 + it("displays FAQ questions", () => { 40 + render(() => <Help />); 41 + expect(screen.getByText("What is Malfestio?")).toBeInTheDocument(); 42 + expect(screen.getByText("What is spaced repetition?")).toBeInTheDocument(); 43 + expect(screen.getByText("What is the AT Protocol?")).toBeInTheDocument(); 44 + expect(screen.getByText("What does 'Fork' mean?")).toBeInTheDocument(); 45 + }); 46 + 47 + it("expands accordion when question is clicked", async () => { 48 + render(() => <Help />); 49 + 50 + expect(screen.queryByText(/Malfestio is a decentralized learning platform/i)).not.toBeInTheDocument(); 51 + 52 + const question = screen.getByText("What is Malfestio?"); 53 + fireEvent.click(question); 54 + expect(screen.getByText(/Malfestio is a decentralized learning platform/i)).toBeInTheDocument(); 55 + }); 56 + 57 + it("collapses accordion when clicked again", async () => { 58 + render(() => <Help />); 59 + 60 + const question = screen.getByText("What is Malfestio?"); 61 + 62 + fireEvent.click(question); 63 + expect(screen.getByText(/Malfestio is a decentralized learning platform/i)).toBeInTheDocument(); 64 + 65 + fireEvent.click(question); 66 + expect(screen.queryByText(/Malfestio is a decentralized learning platform/i)).not.toBeInTheDocument(); 67 + }); 68 + 69 + it("has link back to app", () => { 70 + render(() => <Help />); 71 + const backLink = screen.getByRole("link", { name: /Back to App/i }); 72 + expect(backLink).toHaveAttribute("href", "/"); 73 + }); 74 + 75 + it("shows contact section", () => { 76 + render(() => <Help />); 77 + expect(screen.getByText("Still have questions?")).toBeInTheDocument(); 78 + expect(screen.getByText("Bluesky")).toBeInTheDocument(); 79 + expect(screen.getByText("GitHub")).toBeInTheDocument(); 80 + }); 81 + 82 + it("includes footer", () => { 83 + render(() => <Help />); 84 + expect(screen.getByTestId("footer")).toBeInTheDocument(); 85 + }); 86 + });