From 587dcc04de1fdb8c08e82937abbdca718a940427 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 10 Jul 2026 21:38:08 +0200 Subject: [PATCH] feat: richer profile stats, wrapup min-count filter, watch medium distribution - filter wrapup top directors/actors with count < 2 - add watch_medium to wrapup report + adapters - profile trends: genre breakdown, rating histogram, watch medium dist - fix unused StarDisplay import in movie detail --- crates/adapters/postgres/src/models.rs | 18 +++ crates/adapters/postgres/src/stats.rs | 87 ++++++++++++--- crates/adapters/postgres/src/wrapup.rs | 6 +- crates/adapters/sqlite/src/models.rs | 18 +++ crates/adapters/sqlite/src/stats.rs | 87 ++++++++++++--- crates/adapters/sqlite/src/wrapup.rs | 6 +- crates/api-types/src/users.rs | 15 +++ .../application/src/wrapup/tests/compute.rs | 1 + crates/domain/src/models/mod.rs | 4 +- crates/domain/src/models/stats.rs | 15 +++ crates/domain/src/models/wrapup.rs | 8 ++ .../src/services/tests/wrapup_analyzer.rs | 1 + crates/domain/src/services/wrapup_analyzer.rs | 21 ++++ crates/domain/src/testing/fakes.rs | 3 + crates/presentation/src/handlers/users.rs | 103 +++++++++--------- spa/src/components/profile-view.tsx | 57 +++++++++- spa/src/features/users.ts | 15 +++ spa/src/features/wrapup.ts | 1 + spa/src/routes/_app/movies.$id.tsx | 1 - spa/src/routes/_app/wrapup.$id.tsx | 29 +++++ 20 files changed, 411 insertions(+), 85 deletions(-) diff --git a/crates/adapters/postgres/src/models.rs b/crates/adapters/postgres/src/models.rs index ac50724..006d0be 100644 --- a/crates/adapters/postgres/src/models.rs +++ b/crates/adapters/postgres/src/models.rs @@ -257,3 +257,21 @@ pub(crate) struct MonthlyRatingRow { pub avg_rating: f64, pub count: i64, } + +#[derive(sqlx::FromRow)] +pub(crate) struct GenreCountRow { + pub genre: String, + pub count: i64, +} + +#[derive(sqlx::FromRow)] +pub(crate) struct RatingDistRow { + pub rating: i64, + pub count: i64, +} + +#[derive(sqlx::FromRow)] +pub(crate) struct WatchMediumCountRow { + pub watch_medium: String, + pub count: i64, +} diff --git a/crates/adapters/postgres/src/stats.rs b/crates/adapters/postgres/src/stats.rs index c97739c..482c26d 100644 --- a/crates/adapters/postgres/src/stats.rs +++ b/crates/adapters/postgres/src/stats.rs @@ -7,7 +7,10 @@ use domain::{ }; use sqlx::PgPool; -use crate::models::{DirectorCountRow, MonthlyRatingRow, UserTotalsRow}; +use crate::models::{ + DirectorCountRow, GenreCountRow, MonthlyRatingRow, RatingDistRow, UserTotalsRow, + WatchMediumCountRow, +}; use adapter_common::format_year_month; pub struct PostgresStatsRepository { @@ -97,31 +100,61 @@ impl StatsRepository for PostgresStatsRepository { async fn get_user_trends(&self, user_id: &UserId) -> Result { let uid = user_id.value().to_string(); - let (rating_rows, director_rows) = tokio::try_join!( - sqlx::query_as::<_, MonthlyRatingRow>( - "SELECT to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') AS month, + let (rating_rows, director_rows, genre_rows, rating_dist_rows, medium_rows) = + tokio::try_join!( + sqlx::query_as::<_, MonthlyRatingRow>( + "SELECT to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') AS month, AVG(rating::float) AS avg_rating, COUNT(*) AS count FROM reviews WHERE user_id = $1 AND watched_at >= NOW() - INTERVAL '12 months' GROUP BY to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') ORDER BY to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') ASC" - ) - .bind(&uid) - .fetch_all(&self.pool), - sqlx::query_as::<_, DirectorCountRow>( - "SELECT m.director AS director, COUNT(*) AS count + ) + .bind(&uid) + .fetch_all(&self.pool), + sqlx::query_as::<_, DirectorCountRow>( + "SELECT m.director AS director, COUNT(*) AS count FROM reviews r INNER JOIN movies m ON m.id = r.movie_id WHERE r.user_id = $1 AND m.director IS NOT NULL GROUP BY m.director ORDER BY COUNT(*) DESC LIMIT 5" + ) + .bind(&uid) + .fetch_all(&self.pool), + sqlx::query_as::<_, GenreCountRow>( + "SELECT mg.name AS genre, COUNT(*) AS count + FROM reviews r + INNER JOIN movie_genres mg ON mg.movie_id = r.movie_id + WHERE r.user_id = $1 + GROUP BY mg.name + ORDER BY COUNT(*) DESC + LIMIT 5" + ) + .bind(&uid) + .fetch_all(&self.pool), + sqlx::query_as::<_, RatingDistRow>( + "SELECT rating, COUNT(*) AS count + FROM reviews + WHERE user_id = $1 + GROUP BY rating + ORDER BY rating ASC" + ) + .bind(&uid) + .fetch_all(&self.pool), + sqlx::query_as::<_, WatchMediumCountRow>( + "SELECT watch_medium, COUNT(*) AS count + FROM reviews + WHERE user_id = $1 AND watch_medium IS NOT NULL + GROUP BY watch_medium + ORDER BY COUNT(*) DESC" + ) + .bind(&uid) + .fetch_all(&self.pool) ) - .bind(&uid) - .fetch_all(&self.pool) - ) - .map_err(adapter_common::map_sqlx_error)?; + .map_err(adapter_common::map_sqlx_error)?; let max_director_count = director_rows.iter().map(|d| d.count).max().unwrap_or(1); @@ -143,10 +176,38 @@ impl StatsRepository for PostgresStatsRepository { }) .collect(); + let top_genres = genre_rows + .into_iter() + .map(|g| domain::models::stats::GenreStat { + genre: g.genre, + count: g.count, + }) + .collect(); + + let rating_distribution = { + let mut dist = [0i64; 5]; + for r in &rating_dist_rows { + let idx = (r.rating as usize).saturating_sub(1).min(4); + dist[idx] = r.count; + } + dist + }; + + let watch_medium_distribution = medium_rows + .into_iter() + .map(|m| domain::models::stats::WatchMediumStat { + medium: m.watch_medium, + count: m.count, + }) + .collect(); + Ok(UserTrends { monthly_ratings, top_directors, max_director_count, + top_genres, + rating_distribution, + watch_medium_distribution, }) } } diff --git a/crates/adapters/postgres/src/wrapup.rs b/crates/adapters/postgres/src/wrapup.rs index e482e72..44fc643 100644 --- a/crates/adapters/postgres/src/wrapup.rs +++ b/crates/adapters/postgres/src/wrapup.rs @@ -289,7 +289,7 @@ impl WrapUpStatsQuery for PostgresWrapUpStatsQuery { "SELECT r.movie_id, m.title, m.release_year, m.director, m.poster_path, \ r.rating, \ to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, \ - r.user_id, \ + r.user_id, r.watch_medium, \ p.runtime_minutes, p.budget_usd, p.original_language \ FROM reviews r \ INNER JOIN movies m ON m.id = r.movie_id \ @@ -367,6 +367,9 @@ impl WrapUpStatsQuery for PostgresWrapUpStatsQuery { let original_language: Option = row .try_get("original_language") .map_err(adapter_common::map_sqlx_error)?; + let watch_medium: Option = row + .try_get("watch_medium") + .map_err(adapter_common::map_sqlx_error)?; let genres = genres_map.get(&movie_id_str).cloned().unwrap_or_default(); let keywords = keywords_map.get(&movie_id_str).cloned().unwrap_or_default(); @@ -391,6 +394,7 @@ impl WrapUpStatsQuery for PostgresWrapUpStatsQuery { runtime_minutes: runtime_minutes.map(|v| v as u32), budget_usd, original_language, + watch_medium, genres, keywords, cast_names, diff --git a/crates/adapters/sqlite/src/models.rs b/crates/adapters/sqlite/src/models.rs index acdb4cc..68ccd8d 100644 --- a/crates/adapters/sqlite/src/models.rs +++ b/crates/adapters/sqlite/src/models.rs @@ -264,6 +264,24 @@ pub(crate) struct MonthlyRatingRow { pub count: i64, } +#[derive(sqlx::FromRow)] +pub(crate) struct GenreCountRow { + pub genre: String, + pub count: i64, +} + +#[derive(sqlx::FromRow)] +pub(crate) struct RatingDistRow { + pub rating: i64, + pub count: i64, +} + +#[derive(sqlx::FromRow)] +pub(crate) struct WatchMediumCountRow { + pub watch_medium: String, + pub count: i64, +} + #[derive(sqlx::FromRow)] pub(crate) struct WatchlistRow { pub id: String, diff --git a/crates/adapters/sqlite/src/stats.rs b/crates/adapters/sqlite/src/stats.rs index 93dd663..9f74190 100644 --- a/crates/adapters/sqlite/src/stats.rs +++ b/crates/adapters/sqlite/src/stats.rs @@ -7,7 +7,10 @@ use domain::{ }; use sqlx::SqlitePool; -use crate::models::{DirectorCountRow, MonthlyRatingRow, UserTotalsRow}; +use crate::models::{ + DirectorCountRow, GenreCountRow, MonthlyRatingRow, RatingDistRow, UserTotalsRow, + WatchMediumCountRow, +}; pub struct SqliteStatsRepository { pool: SqlitePool, @@ -98,20 +101,21 @@ impl StatsRepository for SqliteStatsRepository { async fn get_user_trends(&self, user_id: &UserId) -> Result { let uid = user_id.value().to_string(); - let (rating_rows, director_rows) = tokio::try_join!( - sqlx::query_as::<_, MonthlyRatingRow>( - "SELECT strftime('%Y-%m', watched_at) AS month, + let (rating_rows, director_rows, genre_rows, rating_dist_rows, medium_rows) = + tokio::try_join!( + sqlx::query_as::<_, MonthlyRatingRow>( + "SELECT strftime('%Y-%m', watched_at) AS month, AVG(CAST(rating AS REAL)) AS avg_rating, COUNT(*) AS count FROM reviews WHERE user_id = ? AND watched_at >= datetime('now', '-12 months') GROUP BY month ORDER BY month ASC", - ) - .bind(&uid) - .fetch_all(&self.pool), - sqlx::query_as::<_, DirectorCountRow>( - "SELECT m.director, + ) + .bind(&uid) + .fetch_all(&self.pool), + sqlx::query_as::<_, DirectorCountRow>( + "SELECT m.director, COUNT(*) AS count FROM reviews r INNER JOIN movies m ON m.id = r.movie_id @@ -119,11 +123,40 @@ impl StatsRepository for SqliteStatsRepository { GROUP BY m.director ORDER BY COUNT(*) DESC LIMIT 5", + ) + .bind(&uid) + .fetch_all(&self.pool), + sqlx::query_as::<_, GenreCountRow>( + "SELECT mg.name AS genre, COUNT(*) AS count + FROM reviews r + INNER JOIN movie_genres mg ON mg.movie_id = r.movie_id + WHERE r.user_id = ? + GROUP BY mg.name + ORDER BY COUNT(*) DESC + LIMIT 5", + ) + .bind(&uid) + .fetch_all(&self.pool), + sqlx::query_as::<_, RatingDistRow>( + "SELECT rating, COUNT(*) AS count + FROM reviews + WHERE user_id = ? + GROUP BY rating + ORDER BY rating ASC", + ) + .bind(&uid) + .fetch_all(&self.pool), + sqlx::query_as::<_, WatchMediumCountRow>( + "SELECT watch_medium, COUNT(*) AS count + FROM reviews + WHERE user_id = ? AND watch_medium IS NOT NULL + GROUP BY watch_medium + ORDER BY COUNT(*) DESC", + ) + .bind(&uid) + .fetch_all(&self.pool) ) - .bind(&uid) - .fetch_all(&self.pool) - ) - .map_err(adapter_common::map_sqlx_error)?; + .map_err(adapter_common::map_sqlx_error)?; let max_director_count = director_rows.iter().map(|d| d.count).max().unwrap_or(1); @@ -145,10 +178,38 @@ impl StatsRepository for SqliteStatsRepository { }) .collect(); + let top_genres = genre_rows + .into_iter() + .map(|g| domain::models::stats::GenreStat { + genre: g.genre, + count: g.count, + }) + .collect(); + + let rating_distribution = { + let mut dist = [0i64; 5]; + for r in &rating_dist_rows { + let idx = (r.rating as usize).saturating_sub(1).min(4); + dist[idx] = r.count; + } + dist + }; + + let watch_medium_distribution = medium_rows + .into_iter() + .map(|m| domain::models::stats::WatchMediumStat { + medium: m.watch_medium, + count: m.count, + }) + .collect(); + Ok(UserTrends { monthly_ratings, top_directors, max_director_count, + top_genres, + rating_distribution, + watch_medium_distribution, }) } } diff --git a/crates/adapters/sqlite/src/wrapup.rs b/crates/adapters/sqlite/src/wrapup.rs index becda2c..2c5b82d 100644 --- a/crates/adapters/sqlite/src/wrapup.rs +++ b/crates/adapters/sqlite/src/wrapup.rs @@ -301,7 +301,7 @@ impl WrapUpStatsQuery for SqliteWrapUpStatsQuery { let sql = format!( "SELECT r.movie_id, m.title, m.release_year, m.director, m.poster_path, \ - r.rating, r.watched_at, r.user_id, \ + r.rating, r.watched_at, r.user_id, r.watch_medium, \ p.runtime_minutes, p.budget_usd, p.original_language \ FROM reviews r \ INNER JOIN movies m ON m.id = r.movie_id \ @@ -379,6 +379,9 @@ impl WrapUpStatsQuery for SqliteWrapUpStatsQuery { let original_language: Option = row .try_get("original_language") .map_err(adapter_common::map_sqlx_error)?; + let watch_medium: Option = row + .try_get("watch_medium") + .map_err(adapter_common::map_sqlx_error)?; let genres = genres_map.get(&movie_id_str).cloned().unwrap_or_default(); let keywords = keywords_map.get(&movie_id_str).cloned().unwrap_or_default(); @@ -403,6 +406,7 @@ impl WrapUpStatsQuery for SqliteWrapUpStatsQuery { runtime_minutes: runtime_minutes.map(|v| v as u32), budget_usd, original_language, + watch_medium, genres, keywords, cast_names, diff --git a/crates/api-types/src/users.rs b/crates/api-types/src/users.rs index c01a40d..6894da6 100644 --- a/crates/api-types/src/users.rs +++ b/crates/api-types/src/users.rs @@ -60,11 +60,26 @@ pub struct DirectorStatDto { pub count: i64, } +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct GenreStatDto { + pub genre: String, + pub count: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct WatchMediumStatDto { + pub medium: String, + pub count: i64, +} + #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct UserTrendsDto { pub monthly_ratings: Vec, pub top_directors: Vec, pub max_director_count: i64, + pub top_genres: Vec, + pub rating_distribution: [i64; 5], + pub watch_medium_distribution: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] diff --git a/crates/application/src/wrapup/tests/compute.rs b/crates/application/src/wrapup/tests/compute.rs index 76c68c7..1fdee7e 100644 --- a/crates/application/src/wrapup/tests/compute.rs +++ b/crates/application/src/wrapup/tests/compute.rs @@ -27,6 +27,7 @@ fn make_row(title: &str, rating: u8, watched_at: &str) -> WrapUpMovieRow { keywords: vec!["heist".to_string()], cast_names: vec![("Actor A".to_string(), 1, 12345)], cast_profile_paths: vec![None], + watch_medium: None, } } diff --git a/crates/domain/src/models/mod.rs b/crates/domain/src/models/mod.rs index 618bf02..b620941 100644 --- a/crates/domain/src/models/mod.rs +++ b/crates/domain/src/models/mod.rs @@ -4,7 +4,7 @@ mod feed; mod movie; mod refresh_session; mod review; -mod stats; +pub mod stats; mod user; pub mod collections; @@ -26,7 +26,7 @@ pub use federation::*; pub use feed::*; pub use movie::*; pub use review::*; -pub use stats::*; +pub use stats::{DirectorStat, MonthActivity, MonthlyRating, MovieStats, UserStats, UserTrends}; pub use user::*; pub use goal::{Goal, GoalWithProgress}; diff --git a/crates/domain/src/models/stats.rs b/crates/domain/src/models/stats.rs index ccacc67..f871532 100644 --- a/crates/domain/src/models/stats.rs +++ b/crates/domain/src/models/stats.rs @@ -30,11 +30,26 @@ pub struct DirectorStat { pub count: i64, } +#[derive(Clone, Debug)] +pub struct GenreStat { + pub genre: String, + pub count: i64, +} + +#[derive(Clone, Debug)] +pub struct WatchMediumStat { + pub medium: String, + pub count: i64, +} + #[derive(Clone, Debug)] pub struct UserTrends { pub monthly_ratings: Vec, pub top_directors: Vec, pub max_director_count: i64, + pub top_genres: Vec, + pub rating_distribution: [i64; 5], + pub watch_medium_distribution: Vec, } #[derive(Clone, Debug)] diff --git a/crates/domain/src/models/wrapup.rs b/crates/domain/src/models/wrapup.rs index 765c860..fa36aef 100644 --- a/crates/domain/src/models/wrapup.rs +++ b/crates/domain/src/models/wrapup.rs @@ -17,6 +17,7 @@ pub struct WrapUpMovieRow { pub runtime_minutes: Option, pub budget_usd: Option, pub original_language: Option, + pub watch_medium: Option, pub genres: Vec, pub keywords: Vec, pub cast_names: Vec<(String, u32, i64)>, @@ -95,6 +96,12 @@ pub struct LangStat { pub count: u32, } +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct WatchMediumStat { + pub medium: String, + pub count: u32, +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct MonthCount { pub year_month: String, @@ -142,6 +149,7 @@ pub struct WrapUpReport { pub total_budget_watched: Option, pub avg_budget: Option, pub language_distribution: Vec, + pub watch_medium_distribution: Vec, pub oldest_movie: Option, pub newest_movie: Option, diff --git a/crates/domain/src/services/tests/wrapup_analyzer.rs b/crates/domain/src/services/tests/wrapup_analyzer.rs index 98c93cd..b3d985c 100644 --- a/crates/domain/src/services/tests/wrapup_analyzer.rs +++ b/crates/domain/src/services/tests/wrapup_analyzer.rs @@ -23,6 +23,7 @@ fn row(title: &str, rating: u8, ym: &str) -> WrapUpMovieRow { runtime_minutes: Some(100), budget_usd: None, original_language: Some("en".to_string()), + watch_medium: None, genres: vec!["Action".to_string()], keywords: vec![], cast_names: vec![], diff --git a/crates/domain/src/services/wrapup_analyzer.rs b/crates/domain/src/services/wrapup_analyzer.rs index d98fb81..b25f9bc 100644 --- a/crates/domain/src/services/wrapup_analyzer.rs +++ b/crates/domain/src/services/wrapup_analyzer.rs @@ -7,6 +7,8 @@ use crate::models::WrapUpMovieRow; use crate::models::wrapup::*; use crate::models::{ExternalPersonId, PersonId}; +const MIN_PERSON_COUNT: u32 = 2; + pub fn build_report( scope: WrapUpScope, date_range: DateRange, @@ -53,6 +55,7 @@ pub fn build_report( let (total_budget_watched, avg_budget) = compute_budget_stats(rows); let language_distribution = compute_language_stats(rows); + let watch_medium_distribution = compute_watch_medium_stats(rows); let (total_rewatches, most_rewatched_movie, avg_rating_change_on_rewatch) = compute_rewatch_stats(rows); @@ -91,6 +94,7 @@ pub fn build_report( total_budget_watched, avg_budget, language_distribution, + watch_medium_distribution, oldest_movie, newest_movie, total_rewatches, @@ -226,6 +230,7 @@ fn compute_director_stats(rows: &[WrapUpMovieRow]) -> (Vec, u32) { } }) .collect(); + stats.retain(|s| s.count >= MIN_PERSON_COUNT); stats.sort_by(|a, b| { b.count .cmp(&a.count) @@ -270,6 +275,7 @@ fn compute_actor_stats(rows: &[WrapUpMovieRow]) -> (Vec, u32, Vec= MIN_PERSON_COUNT); stats.sort_by(|a, b| { b.count .cmp(&a.count) @@ -367,6 +373,21 @@ fn compute_language_stats(rows: &[WrapUpMovieRow]) -> Vec { stats } +fn compute_watch_medium_stats(rows: &[WrapUpMovieRow]) -> Vec { + let mut counts: HashMap = HashMap::new(); + for r in rows { + if let Some(ref medium) = r.watch_medium { + *counts.entry(medium.clone()).or_default() += 1; + } + } + let mut stats: Vec = counts + .into_iter() + .map(|(medium, count)| WatchMediumStat { medium, count }) + .collect(); + stats.sort_by_key(|s| std::cmp::Reverse(s.count)); + stats +} + fn compute_rewatch_stats(rows: &[WrapUpMovieRow]) -> (u32, Option, Option) { let mut movie_reviews: HashMap> = HashMap::new(); for r in rows { diff --git a/crates/domain/src/testing/fakes.rs b/crates/domain/src/testing/fakes.rs index 97428a5..b39c8af 100644 --- a/crates/domain/src/testing/fakes.rs +++ b/crates/domain/src/testing/fakes.rs @@ -226,6 +226,9 @@ impl StatsRepository for FakeStatsRepository { monthly_ratings: vec![], top_directors: vec![], max_director_count: 0, + top_genres: vec![], + rating_distribution: [0; 5], + watch_medium_distribution: vec![], }) } diff --git a/crates/presentation/src/handlers/users.rs b/crates/presentation/src/handlers/users.rs index 0e5d5fe..0315c85 100644 --- a/crates/presentation/src/handlers/users.rs +++ b/crates/presentation/src/handlers/users.rs @@ -24,9 +24,9 @@ use crate::{ state::AppState, }; use api_types::{ - DiaryResponse, DirectorStatDto, MonthActivityDto, MonthlyRatingDto, ProfileResponse, - UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, - UsersResponse, + DiaryResponse, DirectorStatDto, GenreStatDto, MonthActivityDto, MonthlyRatingDto, + ProfileResponse, UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto, + UserTrendsDto, UsersResponse, WatchMediumStatDto, }; use template_askama::{ EmbedProfileTemplate, MonthlyRatingRow, ProfileSettingsTemplate, ProfileTemplate, @@ -297,32 +297,10 @@ pub async fn get_user_profile( }) .collect(), }) - } else if let Some(t) = profile.trends { - Some(api_types::ProfileViewData::Trends { - trends: UserTrendsDto { - monthly_ratings: t - .monthly_ratings - .into_iter() - .map(|r| MonthlyRatingDto { - year_month: r.year_month, - month_label: r.month_label, - avg_rating: r.avg_rating, - count: r.count, - }) - .collect(), - top_directors: t - .top_directors - .into_iter() - .map(|d| DirectorStatDto { - director: d.director, - count: d.count, - }) - .collect(), - max_director_count: t.max_director_count, - }, - }) } else { - None + profile.trends.map(|t| api_types::ProfileViewData::Trends { + trends: trends_to_dto(t), + }) }; Json(UserProfileResponse { @@ -414,32 +392,10 @@ async fn build_federated_profile_response( offset: p.offset, }, }) - } else if let Some(t) = profile.trends { - Some(api_types::ProfileViewData::Trends { - trends: UserTrendsDto { - monthly_ratings: t - .monthly_ratings - .into_iter() - .map(|r| MonthlyRatingDto { - year_month: r.year_month, - month_label: r.month_label, - avg_rating: r.avg_rating, - count: r.count, - }) - .collect(), - top_directors: t - .top_directors - .into_iter() - .map(|d| DirectorStatDto { - director: d.director, - count: d.count, - }) - .collect(), - max_director_count: t.max_director_count, - }, - }) } else { - None + profile.trends.map(|t| api_types::ProfileViewData::Trends { + trends: trends_to_dto(t), + }) }; let username = fed @@ -473,6 +429,47 @@ async fn build_federated_profile_response( .into_response() } +fn trends_to_dto(t: domain::models::UserTrends) -> UserTrendsDto { + UserTrendsDto { + monthly_ratings: t + .monthly_ratings + .into_iter() + .map(|r| MonthlyRatingDto { + year_month: r.year_month, + month_label: r.month_label, + avg_rating: r.avg_rating, + count: r.count, + }) + .collect(), + top_directors: t + .top_directors + .into_iter() + .map(|d| DirectorStatDto { + director: d.director, + count: d.count, + }) + .collect(), + max_director_count: t.max_director_count, + top_genres: t + .top_genres + .into_iter() + .map(|g| GenreStatDto { + genre: g.genre, + count: g.count, + }) + .collect(), + rating_distribution: t.rating_distribution, + watch_medium_distribution: t + .watch_medium_distribution + .into_iter() + .map(|m| WatchMediumStatDto { + medium: m.medium, + count: m.count, + }) + .collect(), + } +} + // ── HTML ───────────────────────────────────────────────────────────────────── pub async fn get_users_list( diff --git a/spa/src/components/profile-view.tsx b/spa/src/components/profile-view.tsx index 4165a5c..57c169f 100644 --- a/spa/src/components/profile-view.tsx +++ b/spa/src/components/profile-view.tsx @@ -9,11 +9,13 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Skeleton } from "@/components/ui/skeleton" import { Input } from "@/components/ui/input" import { MovieCard } from "@/components/movie-card" +import { RatingHistogram } from "@/components/rating-histogram" import { EmptyState } from "@/components/empty-state" import { SwipeTabs } from "@/components/swipe-tabs" import { VirtualList } from "@/components/virtual-list" import { useInfiniteDiary } from "@/features/diary" import { TimeAgo } from "@/components/time-ago" +import { WATCH_MEDIUMS } from "@/lib/watch-mediums" import type { UserProfileResponse } from "@/features/users" type ProfileViewProps = { @@ -191,6 +193,9 @@ function TrendsView({ avg_rating: number count: number }[] + top_genres: { genre: string; count: number }[] + rating_distribution: number[] + watch_medium_distribution: { medium: string; count: number }[] } } }) { @@ -200,7 +205,7 @@ function TrendsView({ return (
- {data.trends.top_directors.length > 0 && ( + {data.trends.top_directors.some((d) => d.count >= 2) && ( {t("profile.topDirectors")} @@ -221,6 +226,56 @@ function TrendsView({ )} + {data.trends.top_genres.length > 0 && ( + + + {t("profile.topGenres", { defaultValue: "Top Genres" })} + + + {data.trends.top_genres.map((g) => ( +
+ {g.genre} + {t("common.films", { count: g.count })} +
+ ))} +
+
+ )} + + {data.trends.rating_distribution.length > 0 && ( + + + {t("profile.ratingDistribution", { defaultValue: "Rating Distribution" })} + + + + + + )} + + {data.trends.watch_medium_distribution.length > 0 && ( + + + {t("profile.howYouWatch", { defaultValue: "How You Watch" })} + + + {data.trends.watch_medium_distribution.map((wm) => { + const def = WATCH_MEDIUMS.find((d) => d.value === wm.medium) + const Icon = def?.icon + return ( +
+ + {Icon && } + {def ? t(def.labelKey) : wm.medium} + + {t("common.films", { count: wm.count })} +
+ ) + })} +
+
+ )} + {data.trends.monthly_ratings.length > 0 && ( diff --git a/spa/src/features/users.ts b/spa/src/features/users.ts index aa35453..5dcd5a2 100644 --- a/spa/src/features/users.ts +++ b/spa/src/features/users.ts @@ -48,10 +48,25 @@ export const directorStatDtoSchema = z.object({ }) export type DirectorStatDto = z.infer +export const genreStatDtoSchema = z.object({ + genre: z.string(), + count: z.number(), +}) +export type GenreStatDto = z.infer + +export const watchMediumStatDtoSchema = z.object({ + medium: z.string(), + count: z.number(), +}) +export type WatchMediumStatDto = z.infer + export const userTrendsDtoSchema = z.object({ monthly_ratings: z.array(monthlyRatingDtoSchema), top_directors: z.array(directorStatDtoSchema), max_director_count: z.number(), + top_genres: z.array(genreStatDtoSchema).default([]), + rating_distribution: z.array(z.number()).default([]), + watch_medium_distribution: z.array(watchMediumStatDtoSchema).default([]), }) export type UserTrendsDto = z.infer diff --git a/spa/src/features/wrapup.ts b/spa/src/features/wrapup.ts index f43744d..31fd84e 100644 --- a/spa/src/features/wrapup.ts +++ b/spa/src/features/wrapup.ts @@ -99,6 +99,7 @@ export type WrapUpReport = { lowest_rated_movie?: MovieRef first_movie_of_period?: MovieRef last_movie_of_period?: MovieRef + watch_medium_distribution: { medium: string; count: number }[] poster_paths: string[] top_cast_profile_paths: string[] } diff --git a/spa/src/routes/_app/movies.$id.tsx b/spa/src/routes/_app/movies.$id.tsx index 612b283..302b907 100644 --- a/spa/src/routes/_app/movies.$id.tsx +++ b/spa/src/routes/_app/movies.$id.tsx @@ -4,7 +4,6 @@ import { Bookmark, BookmarkCheck, Star, User } from "lucide-react" import { BackButton } from "@/components/back-button" import { CommunityReviews } from "@/components/community-reviews" import { ViewingHistory } from "@/components/viewing-history" -import { StarDisplay } from "@/components/star-display" import { RatingHistogram } from "@/components/rating-histogram" import { HorizontalStrip } from "@/components/horizontal-strip" import { Badge } from "@/components/ui/badge" diff --git a/spa/src/routes/_app/wrapup.$id.tsx b/spa/src/routes/_app/wrapup.$id.tsx index 2046cc8..f9fe34f 100644 --- a/spa/src/routes/_app/wrapup.$id.tsx +++ b/spa/src/routes/_app/wrapup.$id.tsx @@ -16,6 +16,7 @@ import { FunFacts } from "@/components/wrapup-fun-facts" import { RankCard } from "@/components/wrapup-rank-card" import { posterUrl } from "@/lib/api/client" import { fmtUsd } from "@/lib/format" +import { WATCH_MEDIUMS } from "@/lib/watch-mediums" import { useWrapUpReport } from "@/features/wrapup" import { useDocumentTitle } from "@/hooks/use-document-title" import type { MovieRef } from "@/features/wrapup" @@ -144,6 +145,34 @@ function WrapUpReportPage() { )} + {/* Watch Medium Distribution */} + {report.watch_medium_distribution && report.watch_medium_distribution.length > 0 && ( + + + + + {t("wrapup.howYouWatched", { defaultValue: "How You Watched" })} + + + + {report.watch_medium_distribution.map((wm) => { + const def = WATCH_MEDIUMS.find((d) => d.value === wm.medium) + const Icon = def?.icon + return ( +
+ + {Icon && } + {def ? t(def.labelKey) : wm.medium} + + {wm.count} {t("common.films", { count: wm.count })} +
+ ) + })} +
+
+
+ )} + {/* Monthly Activity */} {report.movies_per_month.length > 0 && (