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 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 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<UserTrends, DomainError> {
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,
})
}
}

View File

@@ -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<String> = row
.try_get("original_language")
.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 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,

View File

@@ -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,

View File

@@ -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<UserTrends, DomainError> {
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,
})
}
}

View File

@@ -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<String> = row
.try_get("original_language")
.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 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,

View File

@@ -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<MonthlyRatingDto>,
pub top_directors: Vec<DirectorStatDto>,
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)]

View File

@@ -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,
}
}

View File

@@ -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};

View File

@@ -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<MonthlyRating>,
pub top_directors: Vec<DirectorStat>,
pub max_director_count: i64,
pub top_genres: Vec<GenreStat>,
pub rating_distribution: [i64; 5],
pub watch_medium_distribution: Vec<WatchMediumStat>,
}
#[derive(Clone, Debug)]

View File

@@ -17,6 +17,7 @@ pub struct WrapUpMovieRow {
pub runtime_minutes: Option<u32>,
pub budget_usd: Option<i64>,
pub original_language: Option<String>,
pub watch_medium: Option<String>,
pub genres: Vec<String>,
pub keywords: Vec<String>,
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<i64>,
pub avg_budget: Option<i64>,
pub language_distribution: Vec<LangStat>,
pub watch_medium_distribution: Vec<WatchMediumStat>,
pub oldest_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),
budget_usd: None,
original_language: Some("en".to_string()),
watch_medium: None,
genres: vec!["Action".to_string()],
keywords: vec![],
cast_names: vec![],

View File

@@ -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<PersonStat>, 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<PersonStat>, u32, Vec<St
}
})
.collect();
stats.retain(|s| s.count >= MIN_PERSON_COUNT);
stats.sort_by(|a, b| {
b.count
.cmp(&a.count)
@@ -367,6 +373,21 @@ fn compute_language_stats(rows: &[WrapUpMovieRow]) -> Vec<LangStat> {
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>) {
let mut movie_reviews: HashMap<Uuid, Vec<&WrapUpMovieRow>> = HashMap::new();
for r in rows {

View File

@@ -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![],
})
}

View File

@@ -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(