feat: richer profile stats, wrapup min-count filter, watch medium distribution
Some checks failed
CI / Check / Test (push) Has been cancelled

- 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
This commit is contained in:
2026-07-10 21:38:08 +02:00
parent 498f3b1818
commit 587dcc04de
20 changed files with 411 additions and 85 deletions

View File

@@ -257,3 +257,21 @@ pub(crate) struct MonthlyRatingRow {
pub avg_rating: f64, pub avg_rating: f64,
pub count: i64, 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,
}

View File

@@ -7,7 +7,10 @@ use domain::{
}; };
use sqlx::PgPool; use sqlx::PgPool;
use crate::models::{DirectorCountRow, MonthlyRatingRow, UserTotalsRow}; use crate::models::{
DirectorCountRow, GenreCountRow, MonthlyRatingRow, RatingDistRow, UserTotalsRow,
WatchMediumCountRow,
};
use adapter_common::format_year_month; use adapter_common::format_year_month;
pub struct PostgresStatsRepository { pub struct PostgresStatsRepository {
@@ -97,31 +100,61 @@ impl StatsRepository for PostgresStatsRepository {
async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError> { async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError> {
let uid = user_id.value().to_string(); let uid = user_id.value().to_string();
let (rating_rows, director_rows) = tokio::try_join!( let (rating_rows, director_rows, genre_rows, rating_dist_rows, medium_rows) =
sqlx::query_as::<_, MonthlyRatingRow>( tokio::try_join!(
"SELECT to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') AS month, sqlx::query_as::<_, MonthlyRatingRow>(
"SELECT to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') AS month,
AVG(rating::float) AS avg_rating, AVG(rating::float) AS avg_rating,
COUNT(*) AS count COUNT(*) AS count
FROM reviews FROM reviews
WHERE user_id = $1 AND watched_at >= NOW() - INTERVAL '12 months' WHERE user_id = $1 AND watched_at >= NOW() - INTERVAL '12 months'
GROUP BY to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') 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" ORDER BY to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') ASC"
) )
.bind(&uid) .bind(&uid)
.fetch_all(&self.pool), .fetch_all(&self.pool),
sqlx::query_as::<_, DirectorCountRow>( sqlx::query_as::<_, DirectorCountRow>(
"SELECT m.director AS director, COUNT(*) AS count "SELECT m.director AS director, COUNT(*) AS count
FROM reviews r FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1 AND m.director IS NOT NULL WHERE r.user_id = $1 AND m.director IS NOT NULL
GROUP BY m.director GROUP BY m.director
ORDER BY COUNT(*) DESC ORDER BY COUNT(*) DESC
LIMIT 5" 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) .map_err(adapter_common::map_sqlx_error)?;
.fetch_all(&self.pool)
)
.map_err(adapter_common::map_sqlx_error)?;
let max_director_count = director_rows.iter().map(|d| d.count).max().unwrap_or(1); let max_director_count = director_rows.iter().map(|d| d.count).max().unwrap_or(1);
@@ -143,10 +176,38 @@ impl StatsRepository for PostgresStatsRepository {
}) })
.collect(); .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 { Ok(UserTrends {
monthly_ratings, monthly_ratings,
top_directors, top_directors,
max_director_count, max_director_count,
top_genres,
rating_distribution,
watch_medium_distribution,
}) })
} }
} }

View File

@@ -289,7 +289,7 @@ impl WrapUpStatsQuery for PostgresWrapUpStatsQuery {
"SELECT r.movie_id, m.title, m.release_year, m.director, m.poster_path, \ "SELECT r.movie_id, m.title, m.release_year, m.director, m.poster_path, \
r.rating, \ r.rating, \
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, \ 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 \ p.runtime_minutes, p.budget_usd, p.original_language \
FROM reviews r \ FROM reviews r \
INNER JOIN movies m ON m.id = r.movie_id \ INNER JOIN movies m ON m.id = r.movie_id \
@@ -367,6 +367,9 @@ impl WrapUpStatsQuery for PostgresWrapUpStatsQuery {
let original_language: Option<String> = row let original_language: Option<String> = row
.try_get("original_language") .try_get("original_language")
.map_err(adapter_common::map_sqlx_error)?; .map_err(adapter_common::map_sqlx_error)?;
let watch_medium: Option<String> = 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 genres = genres_map.get(&movie_id_str).cloned().unwrap_or_default();
let keywords = keywords_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), runtime_minutes: runtime_minutes.map(|v| v as u32),
budget_usd, budget_usd,
original_language, original_language,
watch_medium,
genres, genres,
keywords, keywords,
cast_names, cast_names,

View File

@@ -264,6 +264,24 @@ pub(crate) struct MonthlyRatingRow {
pub count: i64, 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)] #[derive(sqlx::FromRow)]
pub(crate) struct WatchlistRow { pub(crate) struct WatchlistRow {
pub id: String, pub id: String,

View File

@@ -7,7 +7,10 @@ use domain::{
}; };
use sqlx::SqlitePool; use sqlx::SqlitePool;
use crate::models::{DirectorCountRow, MonthlyRatingRow, UserTotalsRow}; use crate::models::{
DirectorCountRow, GenreCountRow, MonthlyRatingRow, RatingDistRow, UserTotalsRow,
WatchMediumCountRow,
};
pub struct SqliteStatsRepository { pub struct SqliteStatsRepository {
pool: SqlitePool, pool: SqlitePool,
@@ -98,20 +101,21 @@ impl StatsRepository for SqliteStatsRepository {
async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError> { async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError> {
let uid = user_id.value().to_string(); let uid = user_id.value().to_string();
let (rating_rows, director_rows) = tokio::try_join!( let (rating_rows, director_rows, genre_rows, rating_dist_rows, medium_rows) =
sqlx::query_as::<_, MonthlyRatingRow>( tokio::try_join!(
"SELECT strftime('%Y-%m', watched_at) AS month, sqlx::query_as::<_, MonthlyRatingRow>(
"SELECT strftime('%Y-%m', watched_at) AS month,
AVG(CAST(rating AS REAL)) AS avg_rating, AVG(CAST(rating AS REAL)) AS avg_rating,
COUNT(*) AS count COUNT(*) AS count
FROM reviews FROM reviews
WHERE user_id = ? AND watched_at >= datetime('now', '-12 months') WHERE user_id = ? AND watched_at >= datetime('now', '-12 months')
GROUP BY month GROUP BY month
ORDER BY month ASC", ORDER BY month ASC",
) )
.bind(&uid) .bind(&uid)
.fetch_all(&self.pool), .fetch_all(&self.pool),
sqlx::query_as::<_, DirectorCountRow>( sqlx::query_as::<_, DirectorCountRow>(
"SELECT m.director, "SELECT m.director,
COUNT(*) AS count COUNT(*) AS count
FROM reviews r FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id INNER JOIN movies m ON m.id = r.movie_id
@@ -119,11 +123,40 @@ impl StatsRepository for SqliteStatsRepository {
GROUP BY m.director GROUP BY m.director
ORDER BY COUNT(*) DESC ORDER BY COUNT(*) DESC
LIMIT 5", 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) .map_err(adapter_common::map_sqlx_error)?;
.fetch_all(&self.pool)
)
.map_err(adapter_common::map_sqlx_error)?;
let max_director_count = director_rows.iter().map(|d| d.count).max().unwrap_or(1); let max_director_count = director_rows.iter().map(|d| d.count).max().unwrap_or(1);
@@ -145,10 +178,38 @@ impl StatsRepository for SqliteStatsRepository {
}) })
.collect(); .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 { Ok(UserTrends {
monthly_ratings, monthly_ratings,
top_directors, top_directors,
max_director_count, max_director_count,
top_genres,
rating_distribution,
watch_medium_distribution,
}) })
} }
} }

View File

@@ -301,7 +301,7 @@ impl WrapUpStatsQuery for SqliteWrapUpStatsQuery {
let sql = format!( let sql = format!(
"SELECT r.movie_id, m.title, m.release_year, m.director, m.poster_path, \ "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 \ p.runtime_minutes, p.budget_usd, p.original_language \
FROM reviews r \ FROM reviews r \
INNER JOIN movies m ON m.id = r.movie_id \ INNER JOIN movies m ON m.id = r.movie_id \
@@ -379,6 +379,9 @@ impl WrapUpStatsQuery for SqliteWrapUpStatsQuery {
let original_language: Option<String> = row let original_language: Option<String> = row
.try_get("original_language") .try_get("original_language")
.map_err(adapter_common::map_sqlx_error)?; .map_err(adapter_common::map_sqlx_error)?;
let watch_medium: Option<String> = 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 genres = genres_map.get(&movie_id_str).cloned().unwrap_or_default();
let keywords = keywords_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), runtime_minutes: runtime_minutes.map(|v| v as u32),
budget_usd, budget_usd,
original_language, original_language,
watch_medium,
genres, genres,
keywords, keywords,
cast_names, cast_names,

View File

@@ -60,11 +60,26 @@ pub struct DirectorStatDto {
pub count: i64, 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)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserTrendsDto { pub struct UserTrendsDto {
pub monthly_ratings: Vec<MonthlyRatingDto>, pub monthly_ratings: Vec<MonthlyRatingDto>,
pub top_directors: Vec<DirectorStatDto>, pub top_directors: Vec<DirectorStatDto>,
pub max_director_count: i64, pub max_director_count: i64,
pub top_genres: Vec<GenreStatDto>,
pub rating_distribution: [i64; 5],
pub watch_medium_distribution: Vec<WatchMediumStatDto>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]

View File

@@ -27,6 +27,7 @@ fn make_row(title: &str, rating: u8, watched_at: &str) -> WrapUpMovieRow {
keywords: vec!["heist".to_string()], keywords: vec!["heist".to_string()],
cast_names: vec![("Actor A".to_string(), 1, 12345)], cast_names: vec![("Actor A".to_string(), 1, 12345)],
cast_profile_paths: vec![None], cast_profile_paths: vec![None],
watch_medium: None,
} }
} }

View File

@@ -4,7 +4,7 @@ mod feed;
mod movie; mod movie;
mod refresh_session; mod refresh_session;
mod review; mod review;
mod stats; pub mod stats;
mod user; mod user;
pub mod collections; pub mod collections;
@@ -26,7 +26,7 @@ pub use federation::*;
pub use feed::*; pub use feed::*;
pub use movie::*; pub use movie::*;
pub use review::*; pub use review::*;
pub use stats::*; pub use stats::{DirectorStat, MonthActivity, MonthlyRating, MovieStats, UserStats, UserTrends};
pub use user::*; pub use user::*;
pub use goal::{Goal, GoalWithProgress}; pub use goal::{Goal, GoalWithProgress};

View File

@@ -30,11 +30,26 @@ pub struct DirectorStat {
pub count: i64, 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)] #[derive(Clone, Debug)]
pub struct UserTrends { pub struct UserTrends {
pub monthly_ratings: Vec<MonthlyRating>, pub monthly_ratings: Vec<MonthlyRating>,
pub top_directors: Vec<DirectorStat>, pub top_directors: Vec<DirectorStat>,
pub max_director_count: i64, pub max_director_count: i64,
pub top_genres: Vec<GenreStat>,
pub rating_distribution: [i64; 5],
pub watch_medium_distribution: Vec<WatchMediumStat>,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]

View File

@@ -17,6 +17,7 @@ pub struct WrapUpMovieRow {
pub runtime_minutes: Option<u32>, pub runtime_minutes: Option<u32>,
pub budget_usd: Option<i64>, pub budget_usd: Option<i64>,
pub original_language: Option<String>, pub original_language: Option<String>,
pub watch_medium: Option<String>,
pub genres: Vec<String>, pub genres: Vec<String>,
pub keywords: Vec<String>, pub keywords: Vec<String>,
pub cast_names: Vec<(String, u32, i64)>, pub cast_names: Vec<(String, u32, i64)>,
@@ -95,6 +96,12 @@ pub struct LangStat {
pub count: u32, pub count: u32,
} }
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct WatchMediumStat {
pub medium: String,
pub count: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MonthCount { pub struct MonthCount {
pub year_month: String, pub year_month: String,
@@ -142,6 +149,7 @@ pub struct WrapUpReport {
pub total_budget_watched: Option<i64>, pub total_budget_watched: Option<i64>,
pub avg_budget: Option<i64>, pub avg_budget: Option<i64>,
pub language_distribution: Vec<LangStat>, pub language_distribution: Vec<LangStat>,
pub watch_medium_distribution: Vec<WatchMediumStat>,
pub oldest_movie: Option<MovieRef>, pub oldest_movie: Option<MovieRef>,
pub newest_movie: Option<MovieRef>, pub newest_movie: Option<MovieRef>,

View File

@@ -23,6 +23,7 @@ fn row(title: &str, rating: u8, ym: &str) -> WrapUpMovieRow {
runtime_minutes: Some(100), runtime_minutes: Some(100),
budget_usd: None, budget_usd: None,
original_language: Some("en".to_string()), original_language: Some("en".to_string()),
watch_medium: None,
genres: vec!["Action".to_string()], genres: vec!["Action".to_string()],
keywords: vec![], keywords: vec![],
cast_names: vec![], cast_names: vec![],

View File

@@ -7,6 +7,8 @@ use crate::models::WrapUpMovieRow;
use crate::models::wrapup::*; use crate::models::wrapup::*;
use crate::models::{ExternalPersonId, PersonId}; use crate::models::{ExternalPersonId, PersonId};
const MIN_PERSON_COUNT: u32 = 2;
pub fn build_report( pub fn build_report(
scope: WrapUpScope, scope: WrapUpScope,
date_range: DateRange, date_range: DateRange,
@@ -53,6 +55,7 @@ pub fn build_report(
let (total_budget_watched, avg_budget) = compute_budget_stats(rows); let (total_budget_watched, avg_budget) = compute_budget_stats(rows);
let language_distribution = compute_language_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) = let (total_rewatches, most_rewatched_movie, avg_rating_change_on_rewatch) =
compute_rewatch_stats(rows); compute_rewatch_stats(rows);
@@ -91,6 +94,7 @@ pub fn build_report(
total_budget_watched, total_budget_watched,
avg_budget, avg_budget,
language_distribution, language_distribution,
watch_medium_distribution,
oldest_movie, oldest_movie,
newest_movie, newest_movie,
total_rewatches, total_rewatches,
@@ -226,6 +230,7 @@ fn compute_director_stats(rows: &[WrapUpMovieRow]) -> (Vec<PersonStat>, u32) {
} }
}) })
.collect(); .collect();
stats.retain(|s| s.count >= MIN_PERSON_COUNT);
stats.sort_by(|a, b| { stats.sort_by(|a, b| {
b.count b.count
.cmp(&a.count) .cmp(&a.count)
@@ -270,6 +275,7 @@ fn compute_actor_stats(rows: &[WrapUpMovieRow]) -> (Vec<PersonStat>, u32, Vec<St
} }
}) })
.collect(); .collect();
stats.retain(|s| s.count >= MIN_PERSON_COUNT);
stats.sort_by(|a, b| { stats.sort_by(|a, b| {
b.count b.count
.cmp(&a.count) .cmp(&a.count)
@@ -367,6 +373,21 @@ fn compute_language_stats(rows: &[WrapUpMovieRow]) -> Vec<LangStat> {
stats stats
} }
fn compute_watch_medium_stats(rows: &[WrapUpMovieRow]) -> Vec<WatchMediumStat> {
let mut counts: HashMap<String, u32> = 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<WatchMediumStat> = 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<MovieRef>, Option<f64>) { fn compute_rewatch_stats(rows: &[WrapUpMovieRow]) -> (u32, Option<MovieRef>, Option<f64>) {
let mut movie_reviews: HashMap<Uuid, Vec<&WrapUpMovieRow>> = HashMap::new(); let mut movie_reviews: HashMap<Uuid, Vec<&WrapUpMovieRow>> = HashMap::new();
for r in rows { for r in rows {

View File

@@ -226,6 +226,9 @@ impl StatsRepository for FakeStatsRepository {
monthly_ratings: vec![], monthly_ratings: vec![],
top_directors: vec![], top_directors: vec![],
max_director_count: 0, max_director_count: 0,
top_genres: vec![],
rating_distribution: [0; 5],
watch_medium_distribution: vec![],
}) })
} }

View File

@@ -24,9 +24,9 @@ use crate::{
state::AppState, state::AppState,
}; };
use api_types::{ use api_types::{
DiaryResponse, DirectorStatDto, MonthActivityDto, MonthlyRatingDto, ProfileResponse, DiaryResponse, DirectorStatDto, GenreStatDto, MonthActivityDto, MonthlyRatingDto,
UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, ProfileResponse, UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto,
UsersResponse, UserTrendsDto, UsersResponse, WatchMediumStatDto,
}; };
use template_askama::{ use template_askama::{
EmbedProfileTemplate, MonthlyRatingRow, ProfileSettingsTemplate, ProfileTemplate, EmbedProfileTemplate, MonthlyRatingRow, ProfileSettingsTemplate, ProfileTemplate,
@@ -297,32 +297,10 @@ pub async fn get_user_profile(
}) })
.collect(), .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 { } else {
None profile.trends.map(|t| api_types::ProfileViewData::Trends {
trends: trends_to_dto(t),
})
}; };
Json(UserProfileResponse { Json(UserProfileResponse {
@@ -414,32 +392,10 @@ async fn build_federated_profile_response(
offset: p.offset, 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 { } else {
None profile.trends.map(|t| api_types::ProfileViewData::Trends {
trends: trends_to_dto(t),
})
}; };
let username = fed let username = fed
@@ -473,6 +429,47 @@ async fn build_federated_profile_response(
.into_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 ───────────────────────────────────────────────────────────────────── // ── HTML ─────────────────────────────────────────────────────────────────────
pub async fn get_users_list( pub async fn get_users_list(

View File

@@ -9,11 +9,13 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { MovieCard } from "@/components/movie-card" import { MovieCard } from "@/components/movie-card"
import { RatingHistogram } from "@/components/rating-histogram"
import { EmptyState } from "@/components/empty-state" import { EmptyState } from "@/components/empty-state"
import { SwipeTabs } from "@/components/swipe-tabs" import { SwipeTabs } from "@/components/swipe-tabs"
import { VirtualList } from "@/components/virtual-list" import { VirtualList } from "@/components/virtual-list"
import { useInfiniteDiary } from "@/features/diary" import { useInfiniteDiary } from "@/features/diary"
import { TimeAgo } from "@/components/time-ago" import { TimeAgo } from "@/components/time-ago"
import { WATCH_MEDIUMS } from "@/lib/watch-mediums"
import type { UserProfileResponse } from "@/features/users" import type { UserProfileResponse } from "@/features/users"
type ProfileViewProps = { type ProfileViewProps = {
@@ -191,6 +193,9 @@ function TrendsView({
avg_rating: number avg_rating: number
count: 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 ( return (
<div className="space-y-3"> <div className="space-y-3">
{data.trends.top_directors.length > 0 && ( {data.trends.top_directors.some((d) => d.count >= 2) && (
<Card size="sm"> <Card size="sm">
<CardHeader> <CardHeader>
<CardTitle className="text-sm">{t("profile.topDirectors")}</CardTitle> <CardTitle className="text-sm">{t("profile.topDirectors")}</CardTitle>
@@ -221,6 +226,56 @@ function TrendsView({
</Card> </Card>
)} )}
{data.trends.top_genres.length > 0 && (
<Card size="sm">
<CardHeader>
<CardTitle className="text-sm">{t("profile.topGenres", { defaultValue: "Top Genres" })}</CardTitle>
</CardHeader>
<CardContent>
{data.trends.top_genres.map((g) => (
<div key={g.genre} className="flex items-center justify-between py-1 text-sm">
<span>{g.genre}</span>
<span className="text-xs text-muted-foreground">{t("common.films", { count: g.count })}</span>
</div>
))}
</CardContent>
</Card>
)}
{data.trends.rating_distribution.length > 0 && (
<Card size="sm">
<CardHeader>
<CardTitle className="text-sm">{t("profile.ratingDistribution", { defaultValue: "Rating Distribution" })}</CardTitle>
</CardHeader>
<CardContent>
<RatingHistogram histogram={data.trends.rating_distribution} />
</CardContent>
</Card>
)}
{data.trends.watch_medium_distribution.length > 0 && (
<Card size="sm">
<CardHeader>
<CardTitle className="text-sm">{t("profile.howYouWatch", { defaultValue: "How You Watch" })}</CardTitle>
</CardHeader>
<CardContent>
{data.trends.watch_medium_distribution.map((wm) => {
const def = WATCH_MEDIUMS.find((d) => d.value === wm.medium)
const Icon = def?.icon
return (
<div key={wm.medium} className="flex items-center justify-between py-1 text-sm">
<span className="flex items-center gap-2">
{Icon && <Icon className="size-4 text-muted-foreground" />}
{def ? t(def.labelKey) : wm.medium}
</span>
<span className="text-xs text-muted-foreground">{t("common.films", { count: wm.count })}</span>
</div>
)
})}
</CardContent>
</Card>
)}
{data.trends.monthly_ratings.length > 0 && ( {data.trends.monthly_ratings.length > 0 && (
<Card size="sm"> <Card size="sm">
<CardHeader> <CardHeader>

View File

@@ -48,10 +48,25 @@ export const directorStatDtoSchema = z.object({
}) })
export type DirectorStatDto = z.infer<typeof directorStatDtoSchema> export type DirectorStatDto = z.infer<typeof directorStatDtoSchema>
export const genreStatDtoSchema = z.object({
genre: z.string(),
count: z.number(),
})
export type GenreStatDto = z.infer<typeof genreStatDtoSchema>
export const watchMediumStatDtoSchema = z.object({
medium: z.string(),
count: z.number(),
})
export type WatchMediumStatDto = z.infer<typeof watchMediumStatDtoSchema>
export const userTrendsDtoSchema = z.object({ export const userTrendsDtoSchema = z.object({
monthly_ratings: z.array(monthlyRatingDtoSchema), monthly_ratings: z.array(monthlyRatingDtoSchema),
top_directors: z.array(directorStatDtoSchema), top_directors: z.array(directorStatDtoSchema),
max_director_count: z.number(), 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<typeof userTrendsDtoSchema> export type UserTrendsDto = z.infer<typeof userTrendsDtoSchema>

View File

@@ -99,6 +99,7 @@ export type WrapUpReport = {
lowest_rated_movie?: MovieRef lowest_rated_movie?: MovieRef
first_movie_of_period?: MovieRef first_movie_of_period?: MovieRef
last_movie_of_period?: MovieRef last_movie_of_period?: MovieRef
watch_medium_distribution: { medium: string; count: number }[]
poster_paths: string[] poster_paths: string[]
top_cast_profile_paths: string[] top_cast_profile_paths: string[]
} }

View File

@@ -4,7 +4,6 @@ import { Bookmark, BookmarkCheck, Star, User } from "lucide-react"
import { BackButton } from "@/components/back-button" import { BackButton } from "@/components/back-button"
import { CommunityReviews } from "@/components/community-reviews" import { CommunityReviews } from "@/components/community-reviews"
import { ViewingHistory } from "@/components/viewing-history" import { ViewingHistory } from "@/components/viewing-history"
import { StarDisplay } from "@/components/star-display"
import { RatingHistogram } from "@/components/rating-histogram" import { RatingHistogram } from "@/components/rating-histogram"
import { HorizontalStrip } from "@/components/horizontal-strip" import { HorizontalStrip } from "@/components/horizontal-strip"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"

View File

@@ -16,6 +16,7 @@ import { FunFacts } from "@/components/wrapup-fun-facts"
import { RankCard } from "@/components/wrapup-rank-card" import { RankCard } from "@/components/wrapup-rank-card"
import { posterUrl } from "@/lib/api/client" import { posterUrl } from "@/lib/api/client"
import { fmtUsd } from "@/lib/format" import { fmtUsd } from "@/lib/format"
import { WATCH_MEDIUMS } from "@/lib/watch-mediums"
import { useWrapUpReport } from "@/features/wrapup" import { useWrapUpReport } from "@/features/wrapup"
import { useDocumentTitle } from "@/hooks/use-document-title" import { useDocumentTitle } from "@/hooks/use-document-title"
import type { MovieRef } from "@/features/wrapup" import type { MovieRef } from "@/features/wrapup"
@@ -144,6 +145,34 @@ function WrapUpReportPage() {
</RevealCard> </RevealCard>
)} )}
{/* Watch Medium Distribution */}
{report.watch_medium_distribution && report.watch_medium_distribution.length > 0 && (
<RevealCard>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm">
{t("wrapup.howYouWatched", { defaultValue: "How You Watched" })}
</CardTitle>
</CardHeader>
<CardContent>
{report.watch_medium_distribution.map((wm) => {
const def = WATCH_MEDIUMS.find((d) => d.value === wm.medium)
const Icon = def?.icon
return (
<div key={wm.medium} className="flex items-center justify-between py-1.5 text-sm">
<span className="flex items-center gap-2">
{Icon && <Icon className="size-4 text-muted-foreground" />}
{def ? t(def.labelKey) : wm.medium}
</span>
<span className="text-muted-foreground">{wm.count} {t("common.films", { count: wm.count })}</span>
</div>
)
})}
</CardContent>
</Card>
</RevealCard>
)}
{/* Monthly Activity */} {/* Monthly Activity */}
{report.movies_per_month.length > 0 && ( {report.movies_per_month.length > 0 && (
<RevealCard> <RevealCard>