diff --git a/crates/server/src/api/auth.rs b/crates/server/src/api/auth.rs index 4a0784b..2ec58a5 100644 --- a/crates/server/src/api/auth.rs +++ b/crates/server/src/api/auth.rs @@ -1,6 +1,6 @@ use crate::state::SharedState; -use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; +use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use serde::{Deserialize, Serialize}; use serde_json::json; diff --git a/crates/server/src/api/feed.rs b/crates/server/src/api/feed.rs index 96b9d96..7614a2a 100644 --- a/crates/server/src/api/feed.rs +++ b/crates/server/src/api/feed.rs @@ -60,6 +60,8 @@ mod tests { let card_repo = Arc::new(MockCardRepository::new()) as Arc; let note_repo = Arc::new(MockNoteRepository::new()) as Arc; let oauth_repo = Arc::new(MockOAuthRepository::new()) as Arc; + let prefs_repo = Arc::new(crate::repository::preferences::mock::MockPreferencesRepository::new()) + as Arc; let review_repo = Arc::new(MockReviewRepository::new()) as Arc; let deck_repo = Arc::new(crate::repository::deck::mock::MockDeckRepository::new()) @@ -73,6 +75,7 @@ mod tests { card: card_repo, note: note_repo, oauth: oauth_repo, + prefs: prefs_repo, review: review_repo, social: social_repo, deck: deck_repo, diff --git a/crates/server/src/api/mod.rs b/crates/server/src/api/mod.rs index 71f861c..820f24e 100644 --- a/crates/server/src/api/mod.rs +++ b/crates/server/src/api/mod.rs @@ -5,6 +5,7 @@ pub mod feed; pub mod importer; pub mod note; pub mod oauth; +pub mod preferences; pub mod review; pub mod search; pub mod social; diff --git a/crates/server/src/api/preferences.rs b/crates/server/src/api/preferences.rs new file mode 100644 index 0000000..6ec68d8 --- /dev/null +++ b/crates/server/src/api/preferences.rs @@ -0,0 +1,194 @@ +use crate::middleware::auth::UserContext; +use crate::repository::preferences::{PreferencesRepoError, UpdatePreferences}; +use crate::state::SharedState; + +use axum::{ + Json, + extract::{Extension, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde::Deserialize; +use serde_json::json; + +#[derive(Deserialize)] +pub struct UpdatePreferencesRequest { + pub persona: Option, + pub complete_onboarding: Option, + pub tutorial_deck_completed: Option, +} + +/// GET /api/preferences - Get current user preferences +pub async fn get_preferences( + State(state): State, ctx: Option>, +) -> impl IntoResponse { + let user = match ctx { + Some(Extension(user)) => user, + None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), + }; + + let result = state.prefs_repo.get_or_create(&user.did).await; + + match result { + Ok(prefs) => Json(prefs).into_response(), + Err(e) => { + tracing::error!("Failed to get preferences: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "Failed to get preferences"})), + ) + .into_response() + } + } +} + +/// PUT /api/preferences - Update user preferences +pub async fn update_preferences( + State(state): State, ctx: Option>, + Json(payload): Json, +) -> impl IntoResponse { + let user = match ctx { + Some(Extension(user)) => user, + None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), + }; + + let persona = if let Some(ref p) = payload.persona { + match p.parse() { + Ok(persona) => Some(persona), + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "Invalid persona. Must be 'learner', 'creator', or 'curator'"})), + ) + .into_response(); + } + } + } else { + None + }; + + let updates = UpdatePreferences { + persona, + complete_onboarding: payload.complete_onboarding, + tutorial_deck_completed: payload.tutorial_deck_completed, + }; + + let result = state.prefs_repo.update(&user.did, updates).await; + + match result { + Ok(prefs) => Json(prefs).into_response(), + Err(PreferencesRepoError::NotFound(msg)) => { + (StatusCode::NOT_FOUND, Json(json!({"error": msg}))).into_response() + } + Err(e) => { + tracing::error!("Failed to update preferences: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "Failed to update preferences"})), + ) + .into_response() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::repository::preferences::PreferencesRepository; + use crate::repository::preferences::mock::MockPreferencesRepository; + use crate::state::{AppConfig, AppState, Repositories}; + use std::sync::Arc; + + fn create_test_state_with_prefs(prefs_repo: Arc) -> SharedState { + let pool = crate::db::create_mock_pool(); + let card_repo = Arc::new(crate::repository::card::mock::MockCardRepository::new()) + as Arc; + let note_repo = Arc::new(crate::repository::note::mock::MockNoteRepository::new()) + as Arc; + let oauth_repo = Arc::new(crate::repository::oauth::mock::MockOAuthRepository::new()) + as Arc; + let social_repo = Arc::new(crate::repository::social::mock::MockSocialRepository::new()) + as Arc; + let deck_repo = Arc::new(crate::repository::deck::mock::MockDeckRepository::new()) + as Arc; + let search_repo = Arc::new(crate::repository::search::mock::MockSearchRepository::new()) + as Arc; + let review_repo = Arc::new(crate::repository::review::mock::MockReviewRepository::new()) + as Arc; + + let config = AppConfig { pds_url: "https://bsky.social".to_string() }; + + let repos = Repositories { + card: card_repo, + note: note_repo, + oauth: oauth_repo, + review: review_repo, + social: social_repo, + deck: deck_repo, + search: search_repo, + prefs: prefs_repo, + }; + + AppState::new(pool, repos, config) + } + + #[tokio::test] + async fn test_get_preferences_unauthorized() { + let prefs_repo = Arc::new(MockPreferencesRepository::new()) as Arc; + let state = create_test_state_with_prefs(prefs_repo); + + let response = get_preferences(State(state), None).await.into_response(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn test_get_preferences_success() { + let prefs_repo = Arc::new(MockPreferencesRepository::new()) as Arc; + let state = create_test_state_with_prefs(prefs_repo); + + let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() }; + let response = get_preferences(State(state), Some(Extension(user))) + .await + .into_response(); + + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn test_update_preferences_set_persona() { + let prefs_repo = Arc::new(MockPreferencesRepository::new()) as Arc; + let state = create_test_state_with_prefs(prefs_repo); + + let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() }; + let payload = UpdatePreferencesRequest { + persona: Some("creator".to_string()), + complete_onboarding: Some(true), + tutorial_deck_completed: None, + }; + + let response = update_preferences(State(state), Some(Extension(user)), Json(payload)) + .await + .into_response(); + + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn test_update_preferences_invalid_persona() { + let prefs_repo = Arc::new(MockPreferencesRepository::new()) as Arc; + let state = create_test_state_with_prefs(prefs_repo); + + let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() }; + let payload = UpdatePreferencesRequest { + persona: Some("invalid".to_string()), + complete_onboarding: None, + tutorial_deck_completed: None, + }; + + let response = update_preferences(State(state), Some(Extension(user)), Json(payload)) + .await + .into_response(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } +} diff --git a/crates/server/src/api/review.rs b/crates/server/src/api/review.rs index 16697ff..3c53bf0 100644 --- a/crates/server/src/api/review.rs +++ b/crates/server/src/api/review.rs @@ -147,6 +147,8 @@ mod tests { let card_repo = Arc::new(MockCardRepository::new()) as Arc; let note_repo = Arc::new(MockNoteRepository::new()) as Arc; let oauth_repo = Arc::new(MockOAuthRepository::new()) as Arc; + let preferences_repo = Arc::new(crate::repository::preferences::mock::MockPreferencesRepository::new()) + as Arc; let social_repo = Arc::new(crate::repository::social::mock::MockSocialRepository::new()) as Arc; @@ -161,6 +163,7 @@ mod tests { card: card_repo, note: note_repo, oauth: oauth_repo, + prefs: preferences_repo, review: review_repo, social: social_repo, deck: deck_repo, diff --git a/crates/server/src/api/search.rs b/crates/server/src/api/search.rs index fc9787c..a61345f 100644 --- a/crates/server/src/api/search.rs +++ b/crates/server/src/api/search.rs @@ -90,16 +90,18 @@ mod tests { let review_repo = Arc::new(MockReviewRepository::new()) as Arc; let social_repo = Arc::new(MockSocialRepository::new()) as Arc; let deck_repo = Arc::new(MockDeckRepository::new()) as Arc; - let config = crate::state::AppConfig { pds_url: "https://bsky.social".to_string() }; let auth_cache = Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())); let search_repo_trait = search_repo.clone() as Arc; + let prefs_repo = Arc::new(crate::repository::preferences::mock::MockPreferencesRepository::new()) + as Arc; Arc::new(AppState { pool, card_repo, note_repo, oauth_repo, + prefs_repo, review_repo, social_repo, deck_repo, diff --git a/crates/server/src/api/social.rs b/crates/server/src/api/social.rs index 9503bec..ed0871b 100644 --- a/crates/server/src/api/social.rs +++ b/crates/server/src/api/social.rs @@ -185,6 +185,8 @@ mod tests { let card_repo = Arc::new(MockCardRepository::new()) as Arc; let note_repo = Arc::new(MockNoteRepository::new()) as Arc; let oauth_repo = Arc::new(MockOAuthRepository::new()) as Arc; + let preferences_repo = Arc::new(crate::repository::preferences::mock::MockPreferencesRepository::new()) + as Arc; let review_repo = Arc::new(MockReviewRepository::new()) as Arc; let deck_repo = Arc::new(crate::repository::deck::mock::MockDeckRepository::new()) @@ -199,6 +201,7 @@ mod tests { card_repo, note_repo, oauth_repo, + prefs_repo: preferences_repo, review_repo, social_repo, deck_repo, diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index be3f152..49eb906 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -47,6 +47,7 @@ pub async fn start() -> malfestio_core::Result<()> { let deck_repo = std::sync::Arc::new(repository::deck::DbDeckRepository::new(pool.clone())); let card_repo = std::sync::Arc::new(repository::card::DbCardRepository::new(pool.clone())); let note_repo = std::sync::Arc::new(repository::note::DbNoteRepository::new(pool.clone())); + let prefs_repo = std::sync::Arc::new(repository::preferences::DbPreferencesRepository::new(pool.clone())); let review_repo = std::sync::Arc::new(repository::review::DbReviewRepository::new(pool.clone())); let social_repo = std::sync::Arc::new(repository::social::DbSocialRepository::new(pool.clone())); @@ -59,6 +60,7 @@ pub async fn start() -> malfestio_core::Result<()> { deck: deck_repo, card: card_repo, note: note_repo, + prefs: prefs_repo, review: review_repo, social: social_repo, search: search_repo, @@ -81,6 +83,8 @@ pub async fn start() -> malfestio_core::Result<()> { .route("/social/unfollow/{did}", post(api::social::unfollow)) .route("/decks/{id}/comments", post(api::social::add_comment)) .route("/feeds/follows", get(api::feed::get_feed_follows)) + .route("/preferences", get(api::preferences::get_preferences)) + .route("/preferences", axum::routing::put(api::preferences::update_preferences)) .layer(axum_middleware::from_fn_with_state( state.clone(), middleware::auth::auth_middleware, diff --git a/crates/server/src/repository/mod.rs b/crates/server/src/repository/mod.rs index 5de24e8..94e3688 100644 --- a/crates/server/src/repository/mod.rs +++ b/crates/server/src/repository/mod.rs @@ -2,6 +2,7 @@ pub mod card; pub mod deck; pub mod note; pub mod oauth; +pub mod preferences; pub mod review; pub mod search; pub mod social; diff --git a/crates/server/src/repository/preferences.rs b/crates/server/src/repository/preferences.rs new file mode 100644 index 0000000..c9d94ff --- /dev/null +++ b/crates/server/src/repository/preferences.rs @@ -0,0 +1,334 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug)] +pub enum PreferencesRepoError { + DatabaseError(String), + NotFound(String), +} + +/// User persona for personalized experience +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Persona { + Learner, + Creator, + Curator, +} + +impl std::fmt::Display for Persona { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Persona::Learner => write!(f, "learner"), + Persona::Creator => write!(f, "creator"), + Persona::Curator => write!(f, "curator"), + } + } +} + +impl std::str::FromStr for Persona { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "learner" => Ok(Persona::Learner), + "creator" => Ok(Persona::Creator), + "curator" => Ok(Persona::Curator), + _ => Err(format!("Invalid persona: {}", s)), + } + } +} + +/// User preferences for onboarding and personalization +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct UserPreferences { + #[serde(default)] + pub user_did: String, + pub persona: Option, + pub onboarding_completed_at: Option>, + #[serde(default)] + pub tutorial_deck_completed: bool, +} + +/// Update request for user preferences +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdatePreferences { + pub persona: Option, + pub complete_onboarding: Option, + pub tutorial_deck_completed: Option, +} + +#[async_trait] +pub trait PreferencesRepository: Send + Sync { + /// Get user preferences, creating default if not exists + async fn get_or_create(&self, user_did: &str) -> Result; + + /// Update user preferences + async fn update(&self, user_did: &str, updates: UpdatePreferences) + -> Result; +} + +pub struct DbPreferencesRepository { + pool: crate::db::DbPool, +} + +impl DbPreferencesRepository { + pub fn new(pool: crate::db::DbPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl PreferencesRepository for DbPreferencesRepository { + async fn get_or_create(&self, user_did: &str) -> Result { + let client = self + .pool + .get() + .await + .map_err(|e| PreferencesRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; + + // Try to get existing preferences + let row = client + .query_opt( + "SELECT user_did, persona, onboarding_completed_at, tutorial_deck_completed FROM user_prefs WHERE user_did = $1", + &[&user_did], + ) + .await + .map_err(|e| PreferencesRepoError::DatabaseError(format!("Failed to query preferences: {}", e)))?; + + if let Some(row) = row { + let persona_str: Option = row.get("persona"); + let persona = persona_str.and_then(|s| s.parse().ok()); + + return Ok(UserPreferences { + user_did: row.get("user_did"), + persona, + onboarding_completed_at: row.get("onboarding_completed_at"), + tutorial_deck_completed: row.get("tutorial_deck_completed"), + }); + } + + // Create default preferences + client + .execute( + "INSERT INTO user_prefs (id, user_did) VALUES ($1, $2) ON CONFLICT (user_did) DO NOTHING", + &[&uuid::Uuid::new_v4(), &user_did], + ) + .await + .map_err(|e| PreferencesRepoError::DatabaseError(format!("Failed to create preferences: {}", e)))?; + + Ok(UserPreferences { user_did: user_did.to_string(), ..Default::default() }) + } + + async fn update( + &self, user_did: &str, updates: UpdatePreferences, + ) -> Result { + let client = self + .pool + .get() + .await + .map_err(|e| PreferencesRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; + + // Ensure record exists first + client + .execute( + "INSERT INTO user_prefs (id, user_did) VALUES ($1, $2) ON CONFLICT (user_did) DO NOTHING", + &[&uuid::Uuid::new_v4(), &user_did], + ) + .await + .map_err(|e| PreferencesRepoError::DatabaseError(format!("Failed to ensure preferences: {}", e)))?; + + // Build update query dynamically + let mut set_clauses = Vec::new(); + let mut param_idx = 2; + + let persona_str = updates.persona.map(|p| p.to_string()); + if updates.persona.is_some() { + set_clauses.push(format!("persona = ${}", param_idx)); + param_idx += 1; + } + + let now = Utc::now(); + let complete_onboarding = updates.complete_onboarding.unwrap_or(false); + + if updates.tutorial_deck_completed.is_some() { + set_clauses.push(format!("tutorial_deck_completed = ${}", param_idx)); + param_idx += 1; + } + + if complete_onboarding { + set_clauses.push(format!("onboarding_completed_at = ${}", param_idx)); + } + + if set_clauses.is_empty() { + return self.get_or_create(user_did).await; + } + + // Build params list - need to handle owned values + let mut param_vec: Vec> = Vec::new(); + param_vec.push(Box::new(user_did.to_string())); + + if let Some(ref persona) = persona_str { + param_vec.push(Box::new(persona.clone())); + } + + if let Some(tutorial) = updates.tutorial_deck_completed { + param_vec.push(Box::new(tutorial)); + } + + if complete_onboarding { + param_vec.push(Box::new(now)); + } + + let params_refs: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = param_vec + .iter() + .map(|p| p.as_ref() as &(dyn tokio_postgres::types::ToSql + Sync)) + .collect(); + + let query = format!("UPDATE user_prefs SET {} WHERE user_did = $1", set_clauses.join(", ")); + + client + .execute(&query, ¶ms_refs) + .await + .map_err(|e| PreferencesRepoError::DatabaseError(format!("Failed to update preferences: {}", e)))?; + + self.get_or_create(user_did).await + } +} + +#[cfg(test)] +pub mod mock { + use super::*; + use std::sync::{Arc, Mutex}; + + #[derive(Clone)] + pub struct MockPreferencesRepository { + pub prefs: Arc>>, + pub should_fail: Arc>, + } + + impl MockPreferencesRepository { + pub fn new() -> Self { + Self { + prefs: Arc::new(Mutex::new(std::collections::HashMap::new())), + should_fail: Arc::new(Mutex::new(false)), + } + } + + #[allow(dead_code)] + pub fn set_should_fail(&self, should_fail: bool) { + *self.should_fail.lock().unwrap() = should_fail; + } + } + + impl Default for MockPreferencesRepository { + fn default() -> Self { + Self::new() + } + } + + #[async_trait] + impl PreferencesRepository for MockPreferencesRepository { + async fn get_or_create(&self, user_did: &str) -> Result { + if *self.should_fail.lock().unwrap() { + return Err(PreferencesRepoError::DatabaseError("Mock failure".to_string())); + } + + let mut prefs = self.prefs.lock().unwrap(); + let entry = prefs + .entry(user_did.to_string()) + .or_insert_with(|| UserPreferences { user_did: user_did.to_string(), ..Default::default() }); + Ok(entry.clone()) + } + + async fn update( + &self, user_did: &str, updates: UpdatePreferences, + ) -> Result { + if *self.should_fail.lock().unwrap() { + return Err(PreferencesRepoError::DatabaseError("Mock failure".to_string())); + } + + let mut prefs = self.prefs.lock().unwrap(); + let entry = prefs + .entry(user_did.to_string()) + .or_insert_with(|| UserPreferences { user_did: user_did.to_string(), ..Default::default() }); + + if let Some(persona) = updates.persona { + entry.persona = Some(persona); + } + + if updates.complete_onboarding.unwrap_or(false) { + entry.onboarding_completed_at = Some(Utc::now()); + } + + if let Some(tutorial) = updates.tutorial_deck_completed { + entry.tutorial_deck_completed = tutorial; + } + + Ok(entry.clone()) + } + } +} + +#[cfg(test)] +mod tests { + use super::mock::MockPreferencesRepository; + use super::*; + + #[tokio::test] + async fn test_get_or_create_returns_default() { + let repo = MockPreferencesRepository::new(); + let prefs = repo.get_or_create("did:plc:test").await.unwrap(); + + assert_eq!(prefs.user_did, "did:plc:test"); + assert!(prefs.persona.is_none()); + assert!(prefs.onboarding_completed_at.is_none()); + assert!(!prefs.tutorial_deck_completed); + } + + #[tokio::test] + async fn test_update_persona() { + let repo = MockPreferencesRepository::new(); + let prefs = repo + .update( + "did:plc:test", + UpdatePreferences { + persona: Some(Persona::Creator), + complete_onboarding: None, + tutorial_deck_completed: None, + }, + ) + .await + .unwrap(); + + assert_eq!(prefs.persona, Some(Persona::Creator)); + } + + #[tokio::test] + async fn test_complete_onboarding() { + let repo = MockPreferencesRepository::new(); + let prefs = repo + .update( + "did:plc:test", + UpdatePreferences { + persona: Some(Persona::Learner), + complete_onboarding: Some(true), + tutorial_deck_completed: None, + }, + ) + .await + .unwrap(); + + assert!(prefs.onboarding_completed_at.is_some()); + assert_eq!(prefs.persona, Some(Persona::Learner)); + } + + #[tokio::test] + async fn test_persona_parse() { + assert_eq!("learner".parse::().unwrap(), Persona::Learner); + assert_eq!("creator".parse::().unwrap(), Persona::Creator); + assert_eq!("curator".parse::().unwrap(), Persona::Curator); + assert!("invalid".parse::().is_err()); + } +} diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs index 0c885e9..1db347b 100644 --- a/crates/server/src/state.rs +++ b/crates/server/src/state.rs @@ -4,6 +4,7 @@ use crate::repository::card::CardRepository; use crate::repository::deck::DeckRepository; use crate::repository::note::NoteRepository; use crate::repository::oauth::OAuthRepository; +use crate::repository::preferences::PreferencesRepository; use crate::repository::review::ReviewRepository; use crate::repository::search::SearchRepository; use crate::repository::social::SocialRepository; @@ -27,6 +28,7 @@ pub struct Repositories { pub deck: Arc, pub card: Arc, pub note: Arc, + pub prefs: Arc, pub review: Arc, pub social: Arc, pub search: Arc, @@ -38,6 +40,7 @@ pub struct AppState { pub deck_repo: Arc, pub note_repo: Arc, pub oauth_repo: Arc, + pub prefs_repo: Arc, pub review_repo: Arc, pub social_repo: Arc, pub search_repo: Arc, @@ -54,6 +57,7 @@ impl AppState { deck_repo: repos.deck, card_repo: repos.card, note_repo: repos.note, + prefs_repo: repos.prefs, review_repo: repos.review, social_repo: repos.social, search_repo: repos.search, @@ -68,16 +72,20 @@ impl AppState { oauth_repo: Arc, ) -> SharedState { use crate::repository; + let review_repo = Arc::new(repository::review::mock::MockReviewRepository::new()) as Arc; let social_repo = Arc::new(repository::social::mock::MockSocialRepository::new()) as Arc; let search_repo = Arc::new(repository::search::mock::MockSearchRepository::new()) as Arc; let deck_repo = Arc::new(repository::deck::mock::MockDeckRepository::new()) as Arc; let config = AppConfig { pds_url: "https://bsky.social".to_string() }; + let prefs_repo = + Arc::new(repository::preferences::mock::MockPreferencesRepository::new()) as Arc; let repos = Repositories { card: card_repo, note: note_repo, oauth: oauth_repo, + prefs: prefs_repo, review: review_repo, social: social_repo, search: search_repo, diff --git a/docs/core-user-journeys.md b/docs/core-user-journeys.md index 210ddf3..d96cb6d 100644 --- a/docs/core-user-journeys.md +++ b/docs/core-user-journeys.md @@ -11,9 +11,9 @@ This document outlines the core user journeys and detailed user flows for Malfes 1. **Import**: User inputs a URL (Article) or pastes text. 2. **Generate**: System extracts metadata (and optionally snapshots content). 3. **Authoring**: - * User highlights key sections in the source. - * User creates **Notes** linked to highlights. - * User generates **Cards** (Flashcards) from Notes or directly from source. + - User highlights key sections in the source. + - User creates **Notes** linked to highlights. + - User generates **Cards** (Flashcards) from Notes or directly from source. 4. **Assembly**: User organizes Cards into a **Deck**. 5. **Publish**: User sets visibility (e.g., Public) and publishes the Deck. 6. **Result**: The Deck is now a shareable Artifact (ATProto record). @@ -74,13 +74,13 @@ This document outlines the core user journeys and detailed user flows for Malfes 1. **Session Start**: User opens the app/daily study mode. 2. **Review Queue**: System presents cards due for review based on SRS algorithm (e.g., SM-2). 3. **Interaction**: - * User sees **Front** of card. - * User attempts recall. - * User reveals **Back**. + - User sees **Front** of card. + - User attempts recall. + - User reveals **Back**. 4. **Grading**: User self-grades (e.g., 0-5). 5. **Update**: System schedules next review interval. 6. **Progress**: User sees feedback (cards done, streak incremented). - * *Note: All grading/progress data is strictly private.* + - *Note: All grading/progress data is strictly private.* ### Detailed Flows @@ -103,11 +103,11 @@ This document outlines the core user journeys and detailed user flows for Malfes #### Progress Tracking -* **Due count**: Cards needing review today +- **Due count**: Cards needing review today -* **Streak**: Consecutive days studied -* **Reviewed today**: Cards completed this session -* **Interval growth**: SM-2 algorithm increases intervals for mastered cards +- **Streak**: Consecutive days studied +- **Reviewed today**: Cards completed this session +- **Interval growth**: SM-2 algorithm increases intervals for mastered cards #### Keyboard Shortcuts @@ -129,14 +129,14 @@ This document outlines the core user journeys and detailed user flows for Malfes ### High-Level Workflow 1. **Discovery**: - * User follows a Curator. - * User sees a new Deck in their "New from Follows" feed. + - User follows a Curator. + - User sees a new Deck in their "New from Follows" feed. 2. **Acquisition**: User saves/pins the Deck to their library. 3. **Contribution (Forking)**: - * User identifies a gap or error in the Deck. - * User **Forks** the Deck. - * User edits cards or adds new ones. - * User republishes the modified Deck (referencing the original). + - User identifies a gap or error in the Deck. + - User **Forks** the Deck. + - User edits cards or adds new ones. + - User republishes the modified Deck (referencing the original). 4. **Loop**: Original author (or others) can see the fork and potentially merge changes (future scope) or users can switch to the better fork. ## 4. Discussion & Moderation @@ -148,10 +148,10 @@ This document outlines the core user journeys and detailed user flows for Malfes 1. **Context**: A User is viewing a public Card or Deck. 2. **Discuss**: User adds a **Comment** (threaded) asking for clarification. 3. **Report** (Unhappy Path): - * User encounters abusive content/spam. - * User triggers **Report** flow. - * Moderation system receives report. - * Content may be hidden/labeled based on moderation actions. + - User encounters abusive content/spam. + - User triggers **Report** flow. + - Moderation system receives report. + - Content may be hidden/labeled based on moderation actions. ## 5. Lecture Study Workflow @@ -161,11 +161,11 @@ This document outlines the core user journeys and detailed user flows for Malfes 1. **Import**: User provides a Lecture URL (e.g., YouTube/Video). 2. **Structure**: - * User creates an **Outline** of the lecture. - * User adds **Timestamps** to segment the content. + - User creates an **Outline** of the lecture. + - User adds **Timestamps** to segment the content. 3. **Link**: - * User creates Cards specific to timestamped segments. - * Clicking context on a Card jumps video to the specific timestamp. + - User creates Cards specific to timestamped segments. + - Clicking context on a Card jumps video to the specific timestamp. ## Authentication @@ -179,3 +179,58 @@ This document outlines the core user journeys and detailed user flows for Malfes 1. Click avatar in header → "Logout" 2. → redirected to Landing page + +## 6. Onboarding & Personalization + +**Goal**: New users get a personalized experience based on their learning goals. + +### High-Level Workflow + +1. **First Login**: User authenticates for the first time. +2. **Persona Selection**: User sees onboarding dialog with persona options: + - **Learner**: Focus on studying existing content + - **Creator**: Focus on building and sharing decks + - **Curator**: Focus on discovering and organizing content +3. **Personalized Experience**: Empty states and tips adapt to chosen persona. +4. **Progress**: User preferences stored in backend for consistency across sessions. + +### Detailed Flows + +#### First-Time Onboarding + +1. User logs in successfully +2. System fetches preferences from `/api/preferences` +3. If `onboarding_completed_at` is null, show OnboardingDialog +4. User selects persona → Submit +5. Backend stores persona and marks onboarding complete +6. Dialog closes, user sees personalized empty states + +#### Persona-Aware Empty States + +- **Home (Library)**: Tips and actions tailored to persona + - Learners: "Browse Discovery" and "Fork decks you like" + - Creators: "Create New Deck" and "Import from Article" + - Curators: "View Feed" and "Follow creators" + +- **Review**: First-timer guidance explaining SRS for users with no reviews + +## 7. Help & Support + +**Goal**: Users can find answers to common questions. + +### Detailed Flows + +#### Accessing Help + +1. Footer → "Help" link, or navigate to `/help` +2. View FAQ organized by category: + - Getting Started + - Spaced Repetition + - AT Protocol & Privacy + - Community & Sharing +3. Click questions to expand accordion answers + +#### Beta Notice + +- Help page displays prominent notice that Malfestio is in active development +- Links to Bluesky and GitHub for community support diff --git a/docs/todo.md b/docs/todo.md index 4881869..75b2384 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,36 +1,6 @@ # Product + Technical Roadmap -## Protocol + Lexicon Strategy - -- "Artifacts" are publishable records (ATProto Lexicon). -- "Learning state" is private (local DB + your backend sync; not public records). -- Records are distributed and hard to migrate globally; keep mutable/private state out. -- Lexicon evolution rules strongly encourage forward-compatible extensibility. - -### Namespace + NSID conventions - -- `app.malfestio.note` -- `app.malfestio.card` -- `app.malfestio.deck` -- `app.malfestio.source.article` -- `app.malfestio.source.lecture` -- `app.malfestio.collection` -- `app.malfestio.thread.comment` - -### Lexicon basics - -- Lexicon defines record types + XRPC endpoints; JSON-schema-like constraints. -- Use "optional fields" heavily; avoid enums that will calcify the product too early. -- Versioning: add fields, don't rename; never rely on being able to rewrite history. - -### Schema boundaries (important) - -- **Public share layer**: - - decks, cards, notes, collections, comments -- **Private layer**: - - review schedule, lapses, grades, per-card performance, streaks - -### Auth direction +## Auth direction - ATProto is moving toward OAuth for client↔PDS authorization. - Plan for OAuth support even if MVP starts centralized. @@ -71,10 +41,10 @@ **App Vision Content:** -- [ ] Onboarding flow with persona selection (Learner/Creator/Curator) -- [ ] Empty states with helpful prompts for new users +- [x] Onboarding flow with persona selection (Learner/Creator/Curator) +- [x] Empty states with helpful prompts for new users +- [x] Help center/FAQ section (with beta development notice) - [ ] Tutorial/walkthrough for first deck creation -- [ ] Help center or FAQ section -> Should mention that the app is still in development and subject to change. **SEO & Meta:** @@ -123,6 +93,7 @@ - [ ] OAuth login directly to user's PDS (vs. local-only auth) - [ ] Handle resolution via DNS TXT or `/.well-known/atproto-did` + - - [ ] DPoP token binding for secure API calls **Sync & Conflict Resolution:** diff --git a/lexicons/README.md b/lexicons/README.md index 317292c..5843a58 100644 --- a/lexicons/README.md +++ b/lexicons/README.md @@ -2,6 +2,36 @@ This directory contains the Lexicon definitions for the malfestio's public records. +## Protocol + Lexicon Strategy + +- "Artifacts" are publishable records (ATProto Lexicon). +- "Learning state" is private (local DB + your backend sync; not public records). +- Records are distributed and hard to migrate globally; keep mutable/private state out. +- Lexicon evolution rules strongly encourage forward-compatible extensibility. + +### Namespace + NSID conventions + +- `app.malfestio.note` +- `app.malfestio.card` +- `app.malfestio.deck` +- `app.malfestio.source.article` +- `app.malfestio.source.lecture` +- `app.malfestio.collection` +- `app.malfestio.thread.comment` + +### Lexicon basics + +- Lexicon defines record types + XRPC endpoints; JSON-schema-like constraints. +- Use "optional fields" heavily; avoid enums that will calcify the product too early. +- Versioning: add fields, don't rename; never rely on being able to rewrite history. + +### Schema boundaries (important) + +- **Public share layer**: + - decks, cards, notes, collections, comments +- **Private layer**: + - review schedule, lapses, grades, per-card performance, streaks + ## Evolution Rules 1. **Additive Changes Only**: You can add new optional fields to existing records. diff --git a/migrations/008_2025_12_30_user_prefs.sql b/migrations/008_2025_12_30_user_prefs.sql new file mode 100644 index 0000000..f4bef77 --- /dev/null +++ b/migrations/008_2025_12_30_user_prefs.sql @@ -0,0 +1,17 @@ +-- User preferences for onboarding and personalization +-- Tracks onboarding completion and user persona selection + +CREATE TABLE user_prefs ( + id UUID PRIMARY KEY, + user_did TEXT NOT NULL UNIQUE, + persona TEXT, -- 'learner' | 'creator' | 'curator' | NULL + onboarding_completed_at TIMESTAMPTZ, + tutorial_deck_completed BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_user_prefs_did ON user_prefs(user_did); + +CREATE TRIGGER update_user_prefs_updated_at BEFORE UPDATE ON user_prefs + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/web/src/App.tsx b/web/src/App.tsx index 3bc59fd..31ce4cd 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,10 +1,13 @@ import { AppLayout } from "$components/layout/AppLayout"; -import { authStore } from "$lib/store"; +import { OnboardingDialog } from "$components/OnboardingDialog"; +import type { Persona } from "$lib/model"; +import { authStore, preferencesStore } from "$lib/store"; import About from "$pages/About"; import DeckNew from "$pages/DeckNew"; import DeckView from "$pages/DeckView"; import Discovery from "$pages/Discovery"; import Feed from "$pages/Feed"; +import Help from "$pages/Help"; import Home from "$pages/Home"; import Import from "$pages/Import"; import Landing from "$pages/Landing"; @@ -16,14 +19,34 @@ import Review from "$pages/Review"; import Search from "$pages/Search"; import { Route, Router } from "@solidjs/router"; import type { Component } from "solid-js"; -import { Show } from "solid-js"; +import { createEffect, createSignal, onMount, Show } from "solid-js"; const ProtectedRoute: Component<{ component: Component }> = (props) => { + const [showOnboarding, setShowOnboarding] = createSignal(false); + + onMount(async () => { + if (authStore.isAuthenticated()) { + await preferencesStore.fetchPreferences(); + } + }); + + createEffect(() => { + if (preferencesStore.needsOnboarding()) { + setShowOnboarding(true); + } + }); + + const handleOnboardingComplete = (_persona: Persona) => { + setShowOnboarding(false); + preferencesStore.fetchPreferences(); + }; + return ( }> + ); }; @@ -33,6 +56,7 @@ const App: Component = () => { + } /> } /> } /> diff --git a/web/src/components/OnboardingDialog.tsx b/web/src/components/OnboardingDialog.tsx new file mode 100644 index 0000000..8f730a8 --- /dev/null +++ b/web/src/components/OnboardingDialog.tsx @@ -0,0 +1,107 @@ +import { Button } from "$components/ui/Button"; +import { Dialog } from "$components/ui/Dialog"; +import { api } from "$lib/api"; +import type { Persona } from "$lib/model"; +import { type Component, createSignal, For } from "solid-js"; +import { Motion } from "solid-motionone"; + +type PersonaOption = { id: Persona; title: string; description: string; icon: string; action: string }; + +const personas: PersonaOption[] = [{ + id: "learner", + title: "Learner", + description: "Study content created by others. Master new topics with spaced repetition.", + icon: "i-bi-book", + action: "Browse the Discovery page", +}, { + id: "creator", + title: "Creator", + description: "Build your own decks from articles, lectures, or scratch.", + icon: "i-bi-pencil", + action: "Create your first deck", +}, { + id: "curator", + title: "Curator", + description: "Discover, organize, and share the best learning content with others.", + icon: "i-bi-collection", + action: "Follow creators in your field", +}]; + +type Props = { open: boolean; onComplete: (persona: Persona) => void }; + +export const OnboardingDialog: Component = (props) => { + const [selected, setSelected] = createSignal(null); + const [submitting, setSubmitting] = createSignal(false); + + const handleConfirm = async () => { + const persona = selected(); + if (!persona) return; + + setSubmitting(true); + try { + await api.updatePreferences({ persona, complete_onboarding: true }); + props.onComplete(persona); + } catch (e) { + console.error("Failed to save preferences:", e); + } finally { + setSubmitting(false); + } + }; + + return ( + {}} + title="Welcome to Malfestio" + actions={ + + }> +
+

+ How do you want to use Malfestio? Pick your primary focus — you can always do everything! +

+ +
+ + {(persona, i) => ( + setSelected(persona.id)} + class={`text-left p-4 rounded-lg border transition-all ${ + selected() === persona.id + ? "border-[#0F62FE] bg-[#0F62FE]/10" + : "border-[#393939] bg-[#262626] hover:border-[#525252]" + }`}> +
+
+ +
+
+
+

+ {persona.title} +

+ {selected() === persona.id && } +
+

{persona.description}

+

+ → {persona.action} +

+
+
+
+ )} +
+
+ +

You can change this anytime in Settings.

+
+
+ ); +}; + +export default OnboardingDialog; diff --git a/web/src/components/layout/Footer.tsx b/web/src/components/layout/Footer.tsx index 6d8598f..23f5122 100644 --- a/web/src/components/layout/Footer.tsx +++ b/web/src/components/layout/Footer.tsx @@ -24,6 +24,7 @@ export const Footer: Component = () => (

About + Help ({ api: { updatePreferences: vi.fn() } })); + +describe("OnboardingDialog", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("renders when open", () => { + render(() => {}} />); + expect(screen.getByText("Welcome to Malfestio")).toBeInTheDocument(); + }); + + it("does not render when closed", () => { + render(() => {}} />); + expect(screen.queryByText("Welcome to Malfestio")).not.toBeInTheDocument(); + }); + + it("displays all three persona options", () => { + render(() => {}} />); + expect(screen.getByText("Learner")).toBeInTheDocument(); + expect(screen.getByText("Creator")).toBeInTheDocument(); + expect(screen.getByText("Curator")).toBeInTheDocument(); + }); + + it("shows persona descriptions", () => { + render(() => {}} />); + expect(screen.getByText(/Study content created by others/i)).toBeInTheDocument(); + expect(screen.getByText(/Build your own decks/i)).toBeInTheDocument(); + expect(screen.getByText(/Discover, organize, and share/i)).toBeInTheDocument(); + }); + + it("Get Started button is disabled until a persona is selected", () => { + render(() => {}} />); + const button = screen.getByRole("button", { name: /Get Started/i }); + expect(button).toBeDisabled(); + }); + + it("enables Get Started button after selecting persona", async () => { + render(() => {}} />); + + const learnerOption = screen.getByText("Learner").closest("button"); + fireEvent.click(learnerOption!); + + const button = screen.getByRole("button", { name: /Get Started/i }); + expect(button).not.toBeDisabled(); + }); + + it("calls updatePreferences and onComplete when submitting", async () => { + const { api } = await import("$lib/api"); + vi.mocked(api.updatePreferences).mockResolvedValue( + { + ok: true, + json: () => Promise.resolve({ persona: "creator", onboarding_completed_at: "2024-01-01" }), + } as unknown as Response, + ); + + const onComplete = vi.fn(); + render(() => ); + + const creatorOption = screen.getByText("Creator").closest("button"); + fireEvent.click(creatorOption!); + + const submitButton = screen.getByRole("button", { name: /Get Started/i }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(api.updatePreferences).toHaveBeenCalledWith({ persona: "creator", complete_onboarding: true }); + expect(onComplete).toHaveBeenCalledWith("creator"); + }); + }); + + it("shows submitting state", async () => { + const { api } = await import("$lib/api"); + vi.mocked(api.updatePreferences).mockImplementation(() => + new Promise((resolve) => + setTimeout(() => resolve({ ok: true, json: () => Promise.resolve({}) } as unknown as Response), 100) + ) + ); + + render(() => {}} />); + + const learnerOption = screen.getByText("Learner").closest("button"); + fireEvent.click(learnerOption!); + + const submitButton = screen.getByRole("button", { name: /Get Started/i }); + fireEvent.click(submitButton); + + expect(screen.getByText("Getting Started...")).toBeInTheDocument(); + }); +}); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 228ba45..3875c8e 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -40,6 +40,7 @@ export const api = { getDecks: () => apiFetch("/decks", { method: "GET" }), getDeck: (id: string) => apiFetch(`/decks/${id}`, { method: "GET" }), getDeckCards: (id: string) => apiFetch(`/decks/${id}/cards`, { method: "GET" }), + getPreferences: () => apiFetch("/preferences", { method: "GET" }), getDiscovery: () => apiFetch("/discovery", { method: "GET" }), createDeck: async (payload: CreateDeckPayload) => { const { cards, ...deckPayload } = payload; @@ -76,4 +77,7 @@ export const api = { submitReview: (cardId: string, grade: number) => { return apiFetch("/review/submit", { method: "POST", body: JSON.stringify({ card_id: cardId, grade }) }); }, + updatePreferences: (updates: import("./model").UpdatePreferencesPayload) => { + return apiFetch("/preferences", { method: "PUT", body: JSON.stringify(updates) }); + }, }; diff --git a/web/src/lib/model.ts b/web/src/lib/model.ts index 4a7ffb5..4417404 100644 --- a/web/src/lib/model.ts +++ b/web/src/lib/model.ts @@ -89,3 +89,18 @@ export type SearchResult = { item_type: "deck"; item_id: string; creator_did: st export const asDeck = (r: SearchResult) => (r.item_type === "deck" ? r : undefined); export const asCard = (r: SearchResult) => (r.item_type === "card" ? r : undefined); export const asNote = (r: SearchResult) => (r.item_type === "note" ? r : undefined); + +export type Persona = "learner" | "creator" | "curator"; + +export type UserPreferences = { + user_did: string; + persona: Persona | null; + onboarding_completed_at: string | null; + tutorial_deck_completed: boolean; +}; + +export type UpdatePreferencesPayload = { + persona?: Persona; + complete_onboarding?: boolean; + tutorial_deck_completed?: boolean; +}; diff --git a/web/src/lib/store.ts b/web/src/lib/store.ts index e80d9f0..bed572d 100644 --- a/web/src/lib/store.ts +++ b/web/src/lib/store.ts @@ -1,5 +1,6 @@ import { createRoot, createSignal } from "solid-js"; -import type { User } from "./model"; +import { api } from "./api"; +import type { Persona, User, UserPreferences } from "./model"; export type AuthState = { user: User | null; @@ -39,3 +40,45 @@ function createAuthStore() { } export const authStore = createRoot(createAuthStore); + +function createPreferencesStore() { + const [preferences, setPreferences] = createSignal(null); + const [loading, setLoading] = createSignal(false); + + const fetchPreferences = async () => { + if (!authStore.isAuthenticated()) return; + setLoading(true); + try { + const res = await api.getPreferences(); + if (res.ok) { + setPreferences(await res.json()); + } + } catch (e) { + console.error("Failed to fetch preferences:", e); + } finally { + setLoading(false); + } + }; + + const updatePreferences = async (updates: { persona?: Persona; complete_onboarding?: boolean }) => { + try { + const res = await api.updatePreferences(updates); + if (res.ok) { + setPreferences(await res.json()); + } + } catch (e) { + console.error("Failed to update preferences:", e); + } + }; + + const needsOnboarding = () => { + const prefs = preferences(); + return prefs !== null && prefs.onboarding_completed_at === null; + }; + + const persona = () => preferences()?.persona ?? null; + + return { preferences, loading, fetchPreferences, updatePreferences, needsOnboarding, persona }; +} + +export const preferencesStore = createRoot(createPreferencesStore); diff --git a/web/src/lib/tests/model.test.ts b/web/src/lib/tests/model.test.ts index 1f0955e..fba78f7 100644 --- a/web/src/lib/tests/model.test.ts +++ b/web/src/lib/tests/model.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { asCard, asDeck, asNote, type SearchResult } from "../model"; +import { asCard, asDeck, asNote, type Persona, type SearchResult, type UserPreferences } from "../model"; describe("Type Guards", () => { const deckResult: SearchResult = { @@ -51,3 +51,43 @@ describe("Type Guards", () => { expect(asNote(cardResult)).toBeUndefined(); }); }); + +describe("Persona Types", () => { + it("accepts valid persona values", () => { + const learner: Persona = "learner"; + const creator: Persona = "creator"; + const curator: Persona = "curator"; + + expect(learner).toBe("learner"); + expect(creator).toBe("creator"); + expect(curator).toBe("curator"); + }); +}); + +describe("UserPreferences Types", () => { + it("accepts valid user preferences object", () => { + const prefs: UserPreferences = { + user_did: "did:plc:test", + persona: "learner", + onboarding_completed_at: "2024-01-01T00:00:00Z", + tutorial_deck_completed: false, + }; + + expect(prefs.user_did).toBe("did:plc:test"); + expect(prefs.persona).toBe("learner"); + expect(prefs.onboarding_completed_at).toBe("2024-01-01T00:00:00Z"); + expect(prefs.tutorial_deck_completed).toBe(false); + }); + + it("accepts null values for optional fields", () => { + const prefs: UserPreferences = { + user_did: "did:plc:test", + persona: null, + onboarding_completed_at: null, + tutorial_deck_completed: false, + }; + + expect(prefs.persona).toBeNull(); + expect(prefs.onboarding_completed_at).toBeNull(); + }); +}); diff --git a/web/src/pages/Help.tsx b/web/src/pages/Help.tsx new file mode 100644 index 0000000..f5bf6bc --- /dev/null +++ b/web/src/pages/Help.tsx @@ -0,0 +1,206 @@ +import { Footer } from "$components/layout/Footer"; +import { Button } from "$components/ui/Button"; +import { A } from "@solidjs/router"; +import type { Component, JSX } from "solid-js"; +import { createSignal, For, Show } from "solid-js"; +import { Motion } from "solid-motionone"; + +type FAQItem = { question: string; answer: JSX.Element | string }; + +type FAQSection = { title: string; icon: string; items: FAQItem[] }; + +const faqSections: FAQSection[] = [{ + title: "Getting Started", + icon: "i-bi-rocket-takeoff", + items: [{ + question: "What is Malfestio?", + answer: + "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.", + }, { + question: "How do I create my first deck?", + answer: + "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.", + }, { + question: "What makes Malfestio different from other flashcard apps?", + answer: + "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.", + }], +}, { + title: "Spaced Repetition", + icon: "i-bi-arrow-repeat", + items: [{ + question: "What is spaced repetition?", + answer: + "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.", + }, { + question: "How does the grading system work?", + answer: ( +
    +
  • + 1 (Again): Completely forgot — will review soon +
  • +
  • + 2 (Hard): Struggled to remember +
  • +
  • + 3 (Good): Remembered with some effort +
  • +
  • + 4 (Easy): Remembered easily +
  • +
  • + 5 (Perfect): Instant recall +
  • +
+ ), + }, { + question: "How are review intervals calculated?", + answer: + "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.", + }], +}, { + title: "AT Protocol & Privacy", + icon: "i-bi-globe", + items: [{ + question: "What is the AT Protocol?", + answer: + "The AT Protocol (Authenticated Transfer Protocol) is an open, decentralized social networking protocol. It powers Bluesky and enables portable, user-owned data.", + }, { + question: "Is my study data private?", + answer: + "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.", + }, { + question: "Can I use my existing Bluesky account?", + answer: + "Yes! You can log in with your Bluesky handle and app password. Your decks can be published to your AT Protocol repository.", + }], +}, { + title: "Community & Sharing", + icon: "i-bi-people", + items: [{ + question: "What does 'Fork' mean?", + answer: + "Forking creates a personal copy of someone else's deck. You can study, edit, and improve it. The original deck remains unchanged.", + }, { + question: "How do I discover new decks?", + answer: + "Use the Discovery page to browse trending decks and popular tags. You can also follow creators and see their latest decks in your feed.", + }, { + question: "Can I make my decks private?", + answer: + "Yes! Each deck has visibility settings: Private (only you), Unlisted (anyone with link), Public (discoverable by all), or Shared With (specific users).", + }], +}]; + +const AccordionItem: Component<{ item: FAQItem; index: number }> = (props) => { + const [open, setOpen] = createSignal(false); + + return ( + + + + + {props.item.answer} + + + + ); +}; + +const Help: Component = () => { + return ( +
+
+ +
+ +
+
+ +

+ Beta Notice:{" "} + Malfestio is still in active development. Features may change and some functionality may be incomplete. +

+
+
+ +
+ +

Help Center

+

Find answers to common questions about using Malfestio.

+
+ +
+ + {(section, sectionIndex) => ( + +
+ +

{section.title}

+
+
+ {(item, i) => } +
+
+ )} +
+
+ + +

Still have questions?

+

We're here to help. Reach out on Bluesky or check our GitHub.

+ +
+
+ +
+
+ ); +}; + +export default Help; diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index e3074a3..d03c6ef 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -3,11 +3,12 @@ import { EmptyState } from "$components/ui/EmptyState"; import { Skeleton } from "$components/ui/Skeleton"; import { Tag } from "$components/ui/Tag"; import { api } from "$lib/api"; -import type { Deck } from "$lib/model"; +import type { Deck, Persona } from "$lib/model"; +import { preferencesStore } from "$lib/store"; import { Button } from "$ui/Button"; import { A } from "@solidjs/router"; -import type { Component } from "solid-js"; -import { createResource, For, Index, Show } from "solid-js"; +import type { Component, JSX } from "solid-js"; +import { createMemo, createResource, For, Index, Show } from "solid-js"; import { Motion } from "solid-motionone"; const DeckCard: Component<{ deck: Deck; index: number }> = (props) => ( @@ -60,12 +61,94 @@ const DeckCardSkeleton: Component = () => ( ); +type PersonaTip = { title: string; description: string; icon: JSX.Element; action: JSX.Element; tips: string[] }; + +const personaTips: Record = { + learner: { + title: "Ready to start learning?", + description: "Find decks from the community or create your own study materials.", + icon: , + action: ( + + ), + tips: [ + "Start by exploring public decks in Discovery", + "Fork decks you like to customize them", + "Review cards daily for best retention", + ], + }, + creator: { + title: "Create your first deck!", + description: "Share your knowledge with the community through flashcards.", + icon: , + action: ( + + ), + tips: [ + "Import articles to auto-generate flashcards", + "Use cloze deletions for key terms", + "Add hints to help learners remember", + ], + }, + curator: { + title: "Build your collection", + description: "Discover and organize the best learning content for others.", + icon: , + action: ( + + ), + tips: [ + "Follow creators whose content you enjoy", + "Fork and improve existing decks", + "Use tags to organize by topic", + ], + }, +}; + +const defaultTip: PersonaTip = { + title: "No decks found", + description: "Create your first deck to get started with spaced repetition learning.", + icon: , + action: ( + + + + ), + tips: [], +}; + const Home: Component = () => { const [decks] = createResource(async () => { const res = await api.getDecks(); return res.ok ? ((await res.json()) as Deck[]) : []; }); + const currentTip = createMemo(() => { + const persona = preferencesStore.persona(); + return persona ? personaTips[persona] : defaultTip; + }); + return ( { fallback={
} - action={ - - - - } /> + title={currentTip().title} + description={currentTip().description} + icon={currentTip().icon} + action={currentTip().action} /> + 0}> +
+

Quick Tips

+
    + + {(tip) => ( +
  • + + {tip} +
  • + )} +
    +
+
+
}> {(deck, i) => } diff --git a/web/src/pages/Review.tsx b/web/src/pages/Review.tsx index 6a0d872..670118d 100644 --- a/web/src/pages/Review.tsx +++ b/web/src/pages/Review.tsx @@ -9,6 +9,62 @@ import { useNavigate, useParams } from "@solidjs/router"; import { type Component, createSignal, onMount, Show } from "solid-js"; import { Motion } from "solid-motionone"; +const AllCaughtUp: Component<{ stats: StudyStatsType | null }> = (props) => ( + <> +

+ +

+

All Caught Up!

+

You have no cards due for review right now.

+ +
+

+ New to Spaced Repetition? +

+

+ Cards appear for review based on how well you remember them. The better you know a card, the longer until you + see it again. +

+

Add cards to a deck, and they'll show up here when they're due for review!

+
+
+ +); + +const SessionComplete: Component = () => ( + <> +

+ +

+

Session Complete!

+

Great job! You've reviewed all your due cards.

+ +); + +const KbShortcuts: Component = () => ( + +

Keyboard Shortcuts

+
+
+ Space + Flip card +
+
+ 1-5 + Grade answer +
+
+ E + Edit card +
+
+ Esc + Exit session +
+
+
+); + const Review: Component = () => { const params = useParams<{ deckId?: string }>(); const navigate = useNavigate(); @@ -50,10 +106,10 @@ const Review: Component = () => { when={!sessionActive()} fallback={}> -

{params.deckId ? "Deck Review" : "Daily Review"}

- +

+ Deck Review +

-
{ when={cards().length > 0} fallback={
-

- -

- -

All Caught Up!

-

You have no cards due for review right now.

- - }> - <> -

Session Complete!

-

Great job! You've reviewed all your due cards.

- + }> +
@@ -96,28 +139,7 @@ const Review: Component = () => {
- - -

Keyboard Shortcuts

-
-
- Space - Flip card -
-
- 1-5 - Grade answer -
-
- E - Edit card -
-
- Esc - Exit session -
-
-
+
); diff --git a/web/src/pages/tests/Help.test.tsx b/web/src/pages/tests/Help.test.tsx new file mode 100644 index 0000000..2bbd30e --- /dev/null +++ b/web/src/pages/tests/Help.test.tsx @@ -0,0 +1,86 @@ +import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library"; +import { JSX } from "solid-js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import Help from "../Help"; + +vi.mock( + "@solidjs/router", + () => ({ + A: (props: { href: string; children: JSX.Element; class?: string }) => ( + {props.children} + ), + }), +); + +vi.mock("$components/layout/Footer", () => ({ Footer: () =>
Footer
})); + +describe("Help Page", () => { + afterEach(cleanup); + + it("renders the help page header", () => { + render(() => ); + expect(screen.getByText("Help Center")).toBeInTheDocument(); + }); + + it("displays beta notice", () => { + render(() => ); + expect(screen.getByText("Beta Notice:")).toBeInTheDocument(); + expect(screen.getByText(/Malfestio is still in active development/i)).toBeInTheDocument(); + }); + + it("shows all FAQ categories", () => { + render(() => ); + expect(screen.getByText("Getting Started")).toBeInTheDocument(); + expect(screen.getByText("Spaced Repetition")).toBeInTheDocument(); + expect(screen.getByText("AT Protocol & Privacy")).toBeInTheDocument(); + expect(screen.getByText("Community & Sharing")).toBeInTheDocument(); + }); + + it("displays FAQ questions", () => { + render(() => ); + expect(screen.getByText("What is Malfestio?")).toBeInTheDocument(); + expect(screen.getByText("What is spaced repetition?")).toBeInTheDocument(); + expect(screen.getByText("What is the AT Protocol?")).toBeInTheDocument(); + expect(screen.getByText("What does 'Fork' mean?")).toBeInTheDocument(); + }); + + it("expands accordion when question is clicked", async () => { + render(() => ); + + expect(screen.queryByText(/Malfestio is a decentralized learning platform/i)).not.toBeInTheDocument(); + + const question = screen.getByText("What is Malfestio?"); + fireEvent.click(question); + expect(screen.getByText(/Malfestio is a decentralized learning platform/i)).toBeInTheDocument(); + }); + + it("collapses accordion when clicked again", async () => { + render(() => ); + + const question = screen.getByText("What is Malfestio?"); + + fireEvent.click(question); + expect(screen.getByText(/Malfestio is a decentralized learning platform/i)).toBeInTheDocument(); + + fireEvent.click(question); + expect(screen.queryByText(/Malfestio is a decentralized learning platform/i)).not.toBeInTheDocument(); + }); + + it("has link back to app", () => { + render(() => ); + const backLink = screen.getByRole("link", { name: /Back to App/i }); + expect(backLink).toHaveAttribute("href", "/"); + }); + + it("shows contact section", () => { + render(() => ); + expect(screen.getByText("Still have questions?")).toBeInTheDocument(); + expect(screen.getByText("Bluesky")).toBeInTheDocument(); + expect(screen.getByText("GitHub")).toBeInTheDocument(); + }); + + it("includes footer", () => { + render(() => ); + expect(screen.getByTestId("footer")).toBeInTheDocument(); + }); +});