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={
+
+ {submitting() ? "Getting Started..." : "Get Started"}
+
+ }>
+
+
+ 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 (
+
+ setOpen(!open())} class="w-full py-4 flex items-center justify-between text-left group">
+
+ {props.item.question}
+
+
+
+
+
+ {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}
+
+
+
+ )}
+
+
+
+
+ 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: (
+
+ Create Your First Deck
+
+ ),
+ 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={
-
- Create Your First Deck
-
- } />
+ 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.
- >
+ }>
+
navigate("/")} variant="secondary">Back to Library
@@ -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: () => }));
+
+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();
+ });
+});