feat: goals — "watch N movies in YEAR" with progress bar

Domain: Goal entity, UserSettings (federation toggle), RemoteGoalEntry.
Ports: GoalRepository, UserSettingsRepository, RemoteGoalRepository.
Adapters: sqlite + postgres repos, migrations, AP content query extensions.
Application: CRUD use cases (create/update/delete/get/list), settings use cases.
API: 7 endpoints (/goals CRUD, /users/{id}/goals, /settings) with utoipa docs.
Federation: GoalObject (Note + goal discriminator), outbound broadcast with
per-user toggle, inbound GoalObjectHandler in CompositeObjectHandler.
SPA: API client + hooks, GoalCard (shadcn Card+Progress+DropdownMenu),
GoalSheet (Drawer), profile integration (editable own, read-only others),
federation toggle in settings (Switch).
Classic HTML: glassmorphic goal card on profile, Frutiger Aero styling.
Progress computed from existing reviews — backwards compatible.
This commit is contained in:
2026-06-08 22:37:52 +02:00
parent 213f9a2433
commit fff5f4af2f
67 changed files with 2747 additions and 28 deletions

View File

@@ -33,6 +33,9 @@ pub struct DatabaseOutput {
pub ap_content: Arc<dyn LocalApContentQuery>,
pub wrapup_stats: Arc<dyn domain::ports::WrapUpStatsQuery>,
pub wrapup_repo: Arc<dyn domain::ports::WrapUpRepository>,
pub goal: Arc<dyn domain::ports::GoalRepository>,
pub user_settings: Arc<dyn domain::ports::UserSettingsRepository>,
pub remote_goal: Arc<dyn domain::ports::RemoteGoalRepository>,
pub db_pool: DbPool,
}
@@ -71,6 +74,9 @@ pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result
ap_content: w.ap_content,
wrapup_stats: w.wrapup_stats,
wrapup_repo: w.wrapup_repo,
goal: w.goal,
user_settings: w.user_settings,
remote_goal: w.remote_goal,
db_pool: DbPool::Postgres(w.pool),
})
}
@@ -106,6 +112,9 @@ pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result
ap_content: w.ap_content,
wrapup_stats: w.wrapup_stats,
wrapup_repo: w.wrapup_repo,
goal: w.goal,
user_settings: w.user_settings,
remote_goal: w.remote_goal,
db_pool: DbPool::Sqlite(w.pool),
})
}

View File

@@ -54,13 +54,14 @@ use api_types::search::{
};
use api_types::{
ActivityFeedQueryParams, ActivityFeedResponse, AddToWatchlistRequest, CastMemberDto,
CrewMemberDto, DiaryQueryParams, DiaryResponse, DirectorStatDto, ExportQueryParams, GenreDto,
KeywordDto, LogReviewRequest, LoginRequest, LoginResponse, MonthActivityDto, MonthlyRatingDto,
MovieDetailResponse, MovieProfileResponse, MovieStatsDto, MoviesQueryParams, MoviesResponse,
PaginationQueryParams, ProfileResponse, RegisterRequest, ReviewHistoryResponse,
SocialFeedResponse, SocialReviewDto, UserProfileQueryParams, UserProfileResponse, UserStatsDto,
UserSummaryDto, UserTrendsDto, UsersResponse, WatchlistEntryDto, WatchlistResponse,
WatchlistStatusResponse,
CreateGoalRequest, CrewMemberDto, DiaryQueryParams, DiaryResponse, DirectorStatDto,
ExportQueryParams, GenreDto, GoalDto, GoalsResponse, KeywordDto, LogReviewRequest,
LoginRequest, LoginResponse, MonthActivityDto, MonthlyRatingDto, MovieDetailResponse,
MovieProfileResponse, MovieStatsDto, MoviesQueryParams, MoviesResponse, PaginationQueryParams,
ProfileResponse, RegisterRequest, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto,
UpdateGoalRequest, UpdateUserSettingsRequest, UserProfileQueryParams, UserProfileResponse,
UserSettingsDto, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse, WatchlistEntryDto,
WatchlistResponse, WatchlistStatusResponse,
};
#[cfg(feature = "federation")]
use api_types::{
@@ -1177,6 +1178,19 @@ pub async fn get_user_profile(
entries,
history,
trends,
goals: {
let goals_list = application::goals::list::execute(
&state.app_ctx,
application::goals::queries::ListGoalsQuery { user_id },
)
.await
.unwrap_or_default();
if goals_list.is_empty() {
None
} else {
Some(goals_list.iter().map(goal_with_progress_to_dto).collect())
}
},
})
.into_response()
}
@@ -1505,3 +1519,188 @@ pub async fn get_watchlist_status(
.await?;
Ok(Json(WatchlistStatusResponse { on_watchlist }))
}
// ── Goals ────────────────────────────────────────────────────────────────────
fn goal_with_progress_to_dto(g: &domain::models::GoalWithProgress) -> GoalDto {
GoalDto {
year: g.goal.year(),
target_count: g.goal.target_count(),
current_count: g.current_count,
percentage: g.percentage(),
is_complete: g.is_complete(),
goal_type: g.goal.goal_type().as_str().to_string(),
}
}
#[utoipa::path(
get, path = "/api/v1/goals",
responses(
(status = 200, body = GoalsResponse),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn list_goals(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<GoalsResponse>, ApiError> {
let goals = application::goals::list::execute(
&state.app_ctx,
application::goals::queries::ListGoalsQuery {
user_id: user.0.value(),
},
)
.await?;
Ok(Json(GoalsResponse {
goals: goals.iter().map(goal_with_progress_to_dto).collect(),
}))
}
#[utoipa::path(
post, path = "/api/v1/goals",
request_body = CreateGoalRequest,
responses(
(status = 200, body = GoalDto),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn create_goal(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(req): Json<CreateGoalRequest>,
) -> Result<Json<GoalDto>, ApiError> {
let g = application::goals::create::execute(
&state.app_ctx,
application::goals::commands::CreateGoalCommand {
user_id: user.0.value(),
year: req.year,
target_count: req.target_count,
},
)
.await?;
Ok(Json(goal_with_progress_to_dto(&g)))
}
#[utoipa::path(
put, path = "/api/v1/goals/{year}",
request_body = UpdateGoalRequest,
responses(
(status = 200, body = GoalDto),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Goal not found"),
),
security(("bearer_auth" = []))
)]
pub async fn update_goal(
State(state): State<AppState>,
user: AuthenticatedUser,
Path(year): Path<u16>,
Json(req): Json<UpdateGoalRequest>,
) -> Result<Json<GoalDto>, ApiError> {
let g = application::goals::update::execute(
&state.app_ctx,
application::goals::commands::UpdateGoalCommand {
user_id: user.0.value(),
year,
target_count: req.target_count,
},
)
.await?;
Ok(Json(goal_with_progress_to_dto(&g)))
}
#[utoipa::path(
delete, path = "/api/v1/goals/{year}",
responses(
(status = 204, description = "Goal deleted"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Goal not found"),
),
security(("bearer_auth" = []))
)]
pub async fn delete_goal(
State(state): State<AppState>,
user: AuthenticatedUser,
Path(year): Path<u16>,
) -> Result<StatusCode, ApiError> {
application::goals::delete::execute(
&state.app_ctx,
application::goals::commands::DeleteGoalCommand {
user_id: user.0.value(),
year,
},
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
get, path = "/api/v1/users/{id}/goals",
responses(
(status = 200, body = GoalsResponse),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_user_goals(
State(state): State<AppState>,
AuthenticatedUser(_viewer): AuthenticatedUser,
Path(user_id): Path<Uuid>,
) -> Result<Json<GoalsResponse>, ApiError> {
let goals = application::goals::list::execute(
&state.app_ctx,
application::goals::queries::ListGoalsQuery { user_id },
)
.await?;
Ok(Json(GoalsResponse {
goals: goals.iter().map(goal_with_progress_to_dto).collect(),
}))
}
// ── User Settings ────────────────────────────────────────────────────────────
#[utoipa::path(
get, path = "/api/v1/settings",
responses(
(status = 200, body = UserSettingsDto),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_settings(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<UserSettingsDto>, ApiError> {
let settings =
application::users::get_settings::execute(&state.app_ctx, user.0.value()).await?;
Ok(Json(UserSettingsDto {
federate_goals: settings.federate_goals(),
}))
}
#[utoipa::path(
put, path = "/api/v1/settings",
request_body = UpdateUserSettingsRequest,
responses(
(status = 204, description = "Settings updated"),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn update_settings(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(req): Json<UpdateUserSettingsRequest>,
) -> Result<StatusCode, ApiError> {
application::users::update_settings::execute(
&state.app_ctx,
application::users::update_settings::UpdateUserSettingsCommand {
user_id: user.0.value(),
federate_goals: req.federate_goals,
},
)
.await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -668,6 +668,26 @@ pub async fn get_user_profile(
pending_followers,
sort_by: sort_by_str.to_string(),
search: params.search.clone(),
goals: {
let goals_list = application::goals::list::execute(
&state.app_ctx,
application::goals::queries::ListGoalsQuery {
user_id: profile_user_uuid,
},
)
.await
.unwrap_or_default();
goals_list
.iter()
.map(|g| template_askama::GoalViewData {
year: g.goal.year(),
target_count: g.goal.target_count(),
current_count: g.current_count,
percentage: g.percentage().round(),
is_complete: g.is_complete(),
})
.collect()
},
})
.into_response()
}

View File

@@ -118,6 +118,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
blocklist_repo,
review_store,
remote_watchlist_repo: remote_watchlist_repo.clone(),
remote_goal_repo: Arc::clone(&db.remote_goal),
local_ap_content: Arc::clone(&ap_content_repo),
user_repo: Arc::clone(&db.user),
base_url: app_config.base_url.clone(),
@@ -195,6 +196,9 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
social_query: Arc::new(domain::testing::NoopSocialQueryPort),
wrapup_stats: db.wrapup_stats,
wrapup_repo: db.wrapup_repo,
goal: db.goal,
user_settings: db.user_settings,
remote_goal: db.remote_goal,
},
services: Services {
auth: auth_service,

View File

@@ -436,6 +436,22 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
.route(
"/admin/reindex-search",
routing::post(handlers::api::post_reindex_search),
)
.route(
"/goals",
routing::get(handlers::api::list_goals).post(handlers::api::create_goal),
)
.route(
"/goals/{year}",
routing::put(handlers::api::update_goal).delete(handlers::api::delete_goal),
)
.route(
"/users/{id}/goals",
routing::get(handlers::api::get_user_goals),
)
.route(
"/settings",
routing::get(handlers::api::get_settings).put(handlers::api::update_settings),
);
#[cfg(feature = "federation")]

View File

@@ -650,6 +650,75 @@ impl domain::ports::WrapUpRepository for Panic {
}
}
#[async_trait::async_trait]
impl domain::ports::GoalRepository for Panic {
async fn save(&self, _: &domain::models::Goal) -> Result<(), DomainError> {
panic!()
}
async fn update(&self, _: &domain::models::Goal) -> Result<(), DomainError> {
panic!()
}
async fn delete(
&self,
_: &domain::value_objects::GoalId,
_: &domain::value_objects::UserId,
) -> Result<(), DomainError> {
panic!()
}
async fn find_by_user_and_year(
&self,
_: &domain::value_objects::UserId,
_: u16,
) -> Result<Option<domain::models::Goal>, DomainError> {
panic!()
}
async fn list_for_user(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<domain::models::Goal>, DomainError> {
panic!()
}
async fn count_reviews_in_year(
&self,
_: &domain::value_objects::UserId,
_: u16,
) -> Result<u32, DomainError> {
panic!()
}
}
#[async_trait::async_trait]
impl domain::ports::UserSettingsRepository for Panic {
async fn get(
&self,
_: &domain::value_objects::UserId,
) -> Result<domain::models::UserSettings, DomainError> {
panic!()
}
async fn save(&self, _: &domain::models::UserSettings) -> Result<(), DomainError> {
panic!()
}
}
#[async_trait::async_trait]
impl domain::ports::RemoteGoalRepository for Panic {
async fn save(&self, _: domain::models::RemoteGoalEntry) -> Result<(), DomainError> {
panic!()
}
async fn update_by_ap_id(&self, _: &str, _: u32, _: u32) -> Result<(), DomainError> {
panic!()
}
async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> {
panic!()
}
async fn get_by_actor_url(
&self,
_: &str,
) -> Result<Vec<domain::models::RemoteGoalEntry>, DomainError> {
panic!()
}
}
// --- Single state factory — only auth_service varies ---
pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppState {
@@ -677,6 +746,9 @@ pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppS
social_query: Arc::clone(&repo) as _,
wrapup_stats: Arc::clone(&repo) as _,
wrapup_repo: Arc::clone(&repo) as _,
goal: Arc::clone(&repo) as _,
user_settings: Arc::clone(&repo) as _,
remote_goal: Arc::clone(&repo) as _,
},
services: Services {
auth: auth_service,