feat: richer profile stats, wrapup min-count filter, watch medium distribution
Some checks failed
CI / Check / Test (push) Has been cancelled
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:
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|
||||||
|
|||||||
@@ -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![],
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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![],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user