feat: favorite actors top-5 stat in profile trends
All checks were successful
CI / Check / Test (push) Successful in 1h5m14s
All checks were successful
CI / Check / Test (push) Successful in 1h5m14s
This commit is contained in:
@@ -275,3 +275,12 @@ pub(crate) struct WatchMediumCountRow {
|
|||||||
pub watch_medium: String,
|
pub watch_medium: String,
|
||||||
pub count: i64,
|
pub count: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(crate) struct ActorAppearanceRow {
|
||||||
|
pub tmdb_person_id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub profile_path: Option<String>,
|
||||||
|
pub billing_order: i32,
|
||||||
|
pub movie_id: String,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{DirectorStat, MonthlyRating, UserStats, UserTrends},
|
models::{
|
||||||
|
ActorAppearance, DirectorStat, MonthlyRating, UserStats, UserTrends, compute_top_actors,
|
||||||
|
},
|
||||||
ports::StatsRepository,
|
ports::StatsRepository,
|
||||||
value_objects::UserId,
|
value_objects::UserId,
|
||||||
};
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
DirectorCountRow, GenreCountRow, MonthlyRatingRow, RatingDistRow, UserTotalsRow,
|
ActorAppearanceRow, DirectorCountRow, GenreCountRow, MonthlyRatingRow, RatingDistRow,
|
||||||
WatchMediumCountRow,
|
UserTotalsRow, WatchMediumCountRow,
|
||||||
};
|
};
|
||||||
use adapter_common::format_year_month;
|
use adapter_common::format_year_month;
|
||||||
|
|
||||||
@@ -100,7 +102,7 @@ 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, genre_rows, rating_dist_rows, medium_rows) =
|
let (rating_rows, director_rows, genre_rows, rating_dist_rows, medium_rows, actor_rows) =
|
||||||
tokio::try_join!(
|
tokio::try_join!(
|
||||||
sqlx::query_as::<_, MonthlyRatingRow>(
|
sqlx::query_as::<_, MonthlyRatingRow>(
|
||||||
"SELECT to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') AS month,
|
"SELECT to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') AS month,
|
||||||
@@ -152,6 +154,16 @@ impl StatsRepository for PostgresStatsRepository {
|
|||||||
ORDER BY COUNT(*) DESC"
|
ORDER BY COUNT(*) DESC"
|
||||||
)
|
)
|
||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
|
.fetch_all(&self.pool),
|
||||||
|
sqlx::query_as::<_, ActorAppearanceRow>(
|
||||||
|
"SELECT mc.tmdb_person_id, mc.name, mc.profile_path,
|
||||||
|
mc.billing_order, mc.movie_id
|
||||||
|
FROM reviews r
|
||||||
|
INNER JOIN movie_cast mc ON mc.movie_id = r.movie_id
|
||||||
|
WHERE r.user_id = $1
|
||||||
|
GROUP BY mc.tmdb_person_id, mc.movie_id, mc.name, mc.profile_path, mc.billing_order"
|
||||||
|
)
|
||||||
|
.bind(&uid)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
)
|
)
|
||||||
.map_err(adapter_common::map_sqlx_error)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
@@ -201,6 +213,19 @@ impl StatsRepository for PostgresStatsRepository {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let top_actors = compute_top_actors(
|
||||||
|
actor_rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| ActorAppearance {
|
||||||
|
tmdb_person_id: r.tmdb_person_id as u64,
|
||||||
|
name: r.name,
|
||||||
|
profile_path: r.profile_path,
|
||||||
|
billing_order: r.billing_order as u32,
|
||||||
|
movie_id: r.movie_id,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
|
||||||
Ok(UserTrends {
|
Ok(UserTrends {
|
||||||
monthly_ratings,
|
monthly_ratings,
|
||||||
top_directors,
|
top_directors,
|
||||||
@@ -208,6 +233,7 @@ impl StatsRepository for PostgresStatsRepository {
|
|||||||
top_genres,
|
top_genres,
|
||||||
rating_distribution,
|
rating_distribution,
|
||||||
watch_medium_distribution,
|
watch_medium_distribution,
|
||||||
|
top_actors,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -282,6 +282,15 @@ pub(crate) struct WatchMediumCountRow {
|
|||||||
pub count: i64,
|
pub count: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(crate) struct ActorAppearanceRow {
|
||||||
|
pub tmdb_person_id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub profile_path: Option<String>,
|
||||||
|
pub billing_order: i32,
|
||||||
|
pub movie_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
pub(crate) struct WatchlistRow {
|
pub(crate) struct WatchlistRow {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{DirectorStat, MonthlyRating, UserStats, UserTrends},
|
models::{
|
||||||
|
ActorAppearance, DirectorStat, MonthlyRating, UserStats, UserTrends, compute_top_actors,
|
||||||
|
},
|
||||||
ports::StatsRepository,
|
ports::StatsRepository,
|
||||||
value_objects::UserId,
|
value_objects::UserId,
|
||||||
};
|
};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
DirectorCountRow, GenreCountRow, MonthlyRatingRow, RatingDistRow, UserTotalsRow,
|
ActorAppearanceRow, DirectorCountRow, GenreCountRow, MonthlyRatingRow, RatingDistRow,
|
||||||
WatchMediumCountRow,
|
UserTotalsRow, WatchMediumCountRow,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct SqliteStatsRepository {
|
pub struct SqliteStatsRepository {
|
||||||
@@ -101,7 +103,7 @@ 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, genre_rows, rating_dist_rows, medium_rows) =
|
let (rating_rows, director_rows, genre_rows, rating_dist_rows, medium_rows, actor_rows) =
|
||||||
tokio::try_join!(
|
tokio::try_join!(
|
||||||
sqlx::query_as::<_, MonthlyRatingRow>(
|
sqlx::query_as::<_, MonthlyRatingRow>(
|
||||||
"SELECT strftime('%Y-%m', watched_at) AS month,
|
"SELECT strftime('%Y-%m', watched_at) AS month,
|
||||||
@@ -154,6 +156,16 @@ impl StatsRepository for SqliteStatsRepository {
|
|||||||
ORDER BY COUNT(*) DESC",
|
ORDER BY COUNT(*) DESC",
|
||||||
)
|
)
|
||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
|
.fetch_all(&self.pool),
|
||||||
|
sqlx::query_as::<_, ActorAppearanceRow>(
|
||||||
|
"SELECT mc.tmdb_person_id, mc.name, mc.profile_path,
|
||||||
|
mc.billing_order, mc.movie_id
|
||||||
|
FROM reviews r
|
||||||
|
INNER JOIN movie_cast mc ON mc.movie_id = r.movie_id
|
||||||
|
WHERE r.user_id = ?
|
||||||
|
GROUP BY mc.tmdb_person_id, mc.movie_id",
|
||||||
|
)
|
||||||
|
.bind(&uid)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
)
|
)
|
||||||
.map_err(adapter_common::map_sqlx_error)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
@@ -203,6 +215,19 @@ impl StatsRepository for SqliteStatsRepository {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let top_actors = compute_top_actors(
|
||||||
|
actor_rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| ActorAppearance {
|
||||||
|
tmdb_person_id: r.tmdb_person_id as u64,
|
||||||
|
name: r.name,
|
||||||
|
profile_path: r.profile_path,
|
||||||
|
billing_order: r.billing_order as u32,
|
||||||
|
movie_id: r.movie_id,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
|
||||||
Ok(UserTrends {
|
Ok(UserTrends {
|
||||||
monthly_ratings,
|
monthly_ratings,
|
||||||
top_directors,
|
top_directors,
|
||||||
@@ -210,6 +235,7 @@ impl StatsRepository for SqliteStatsRepository {
|
|||||||
top_genres,
|
top_genres,
|
||||||
rating_distribution,
|
rating_distribution,
|
||||||
watch_medium_distribution,
|
watch_medium_distribution,
|
||||||
|
top_actors,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ pub struct DirectorStatDto {
|
|||||||
pub count: i64,
|
pub count: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct ActorStatDto {
|
||||||
|
pub name: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub profile_path: Option<String>,
|
||||||
|
pub movie_count: u32,
|
||||||
|
pub score: f64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
pub struct GenreStatDto {
|
pub struct GenreStatDto {
|
||||||
pub genre: String,
|
pub genre: String,
|
||||||
@@ -80,6 +89,7 @@ pub struct UserTrendsDto {
|
|||||||
pub top_genres: Vec<GenreStatDto>,
|
pub top_genres: Vec<GenreStatDto>,
|
||||||
pub rating_distribution: [i64; 5],
|
pub rating_distribution: [i64; 5],
|
||||||
pub watch_medium_distribution: Vec<WatchMediumStatDto>,
|
pub watch_medium_distribution: Vec<WatchMediumStatDto>,
|
||||||
|
pub top_actors: Vec<ActorStatDto>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ pub use federation::*;
|
|||||||
pub use feed::*;
|
pub use feed::*;
|
||||||
pub use movie::*;
|
pub use movie::*;
|
||||||
pub use review::*;
|
pub use review::*;
|
||||||
pub use stats::{DirectorStat, MonthActivity, MonthlyRating, MovieStats, UserStats, UserTrends};
|
pub use stats::{
|
||||||
|
ActorAppearance, ActorStat, DirectorStat, MonthActivity, MonthlyRating, MovieStats, UserStats,
|
||||||
|
UserTrends, compute_top_actors,
|
||||||
|
};
|
||||||
pub use user::*;
|
pub use user::*;
|
||||||
|
|
||||||
pub use goal::{Goal, GoalWithProgress};
|
pub use goal::{Goal, GoalWithProgress};
|
||||||
|
|||||||
@@ -50,6 +50,85 @@ pub struct UserTrends {
|
|||||||
pub top_genres: Vec<GenreStat>,
|
pub top_genres: Vec<GenreStat>,
|
||||||
pub rating_distribution: [i64; 5],
|
pub rating_distribution: [i64; 5],
|
||||||
pub watch_medium_distribution: Vec<WatchMediumStat>,
|
pub watch_medium_distribution: Vec<WatchMediumStat>,
|
||||||
|
pub top_actors: Vec<ActorStat>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ActorAppearance {
|
||||||
|
pub tmdb_person_id: u64,
|
||||||
|
pub name: String,
|
||||||
|
pub profile_path: Option<String>,
|
||||||
|
pub billing_order: u32,
|
||||||
|
pub movie_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ActorStat {
|
||||||
|
pub tmdb_person_id: u64,
|
||||||
|
pub name: String,
|
||||||
|
pub profile_path: Option<String>,
|
||||||
|
pub movie_count: u32,
|
||||||
|
pub score: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEAD_BILLING_MAX: u32 = 2;
|
||||||
|
const SUPPORTING_BILLING_MIN: u32 = 3;
|
||||||
|
const SUPPORTING_BILLING_MAX: u32 = 9;
|
||||||
|
|
||||||
|
const LEAD_WEIGHT: f64 = 1.0;
|
||||||
|
const SUPPORTING_WEIGHT: f64 = 0.7;
|
||||||
|
const MINOR_WEIGHT: f64 = 0.2;
|
||||||
|
|
||||||
|
const TOP_ACTORS_LIMIT: usize = 5;
|
||||||
|
|
||||||
|
fn billing_weight(order: u32) -> f64 {
|
||||||
|
match order {
|
||||||
|
0..=LEAD_BILLING_MAX => LEAD_WEIGHT,
|
||||||
|
SUPPORTING_BILLING_MIN..=SUPPORTING_BILLING_MAX => SUPPORTING_WEIGHT,
|
||||||
|
_ => MINOR_WEIGHT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compute_top_actors(appearances: Vec<ActorAppearance>) -> Vec<ActorStat> {
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
struct ActorAccumulator {
|
||||||
|
name: String,
|
||||||
|
profile_path: Option<String>,
|
||||||
|
score: f64,
|
||||||
|
seen_movies: HashSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut actors_by_id: HashMap<u64, ActorAccumulator> = HashMap::new();
|
||||||
|
|
||||||
|
for appearance in appearances {
|
||||||
|
let accumulator = actors_by_id
|
||||||
|
.entry(appearance.tmdb_person_id)
|
||||||
|
.or_insert_with(|| ActorAccumulator {
|
||||||
|
name: appearance.name.clone(),
|
||||||
|
profile_path: appearance.profile_path.clone(),
|
||||||
|
score: 0.0,
|
||||||
|
seen_movies: HashSet::new(),
|
||||||
|
});
|
||||||
|
if accumulator.seen_movies.insert(appearance.movie_id) {
|
||||||
|
accumulator.score += billing_weight(appearance.billing_order);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ranked_actors: Vec<ActorStat> = actors_by_id
|
||||||
|
.into_iter()
|
||||||
|
.map(|(person_id, accumulator)| ActorStat {
|
||||||
|
tmdb_person_id: person_id,
|
||||||
|
name: accumulator.name,
|
||||||
|
profile_path: accumulator.profile_path,
|
||||||
|
movie_count: accumulator.seen_movies.len() as u32,
|
||||||
|
score: accumulator.score,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
ranked_actors.sort_by(|left, right| right.score.total_cmp(&left.score));
|
||||||
|
ranked_actors.truncate(TOP_ACTORS_LIMIT);
|
||||||
|
ranked_actors
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -59,3 +138,7 @@ pub struct MovieStats {
|
|||||||
pub federated_count: u64,
|
pub federated_count: u64,
|
||||||
pub rating_histogram: [u64; 5], // index 0 = 1★, index 4 = 5★
|
pub rating_histogram: [u64; 5], // index 0 = 1★, index 4 = 5★
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/stats.rs"]
|
||||||
|
mod tests;
|
||||||
|
|||||||
71
crates/domain/src/models/tests/stats.rs
Normal file
71
crates/domain/src/models/tests/stats.rs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn appearance(person_id: u64, name: &str, billing: u32, movie: &str) -> ActorAppearance {
|
||||||
|
ActorAppearance {
|
||||||
|
tmdb_person_id: person_id,
|
||||||
|
name: name.to_string(),
|
||||||
|
profile_path: Some(format!("/profile_{person_id}.jpg")),
|
||||||
|
billing_order: billing,
|
||||||
|
movie_id: movie.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_appearances_returns_empty() {
|
||||||
|
assert!(compute_top_actors(vec![]).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lead_scores_higher_than_minor_with_same_movie_count() {
|
||||||
|
let appearances = vec![
|
||||||
|
appearance(1, "Lead Actor", 0, "movie_a"),
|
||||||
|
appearance(1, "Lead Actor", 1, "movie_b"),
|
||||||
|
appearance(2, "Minor Actor", 15, "movie_a"),
|
||||||
|
appearance(2, "Minor Actor", 12, "movie_b"),
|
||||||
|
];
|
||||||
|
let result = compute_top_actors(appearances);
|
||||||
|
assert_eq!(result.len(), 2);
|
||||||
|
assert_eq!(result[0].name, "Lead Actor");
|
||||||
|
assert_eq!(result[0].movie_count, 2);
|
||||||
|
assert!(result[0].score > result[1].score);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_movie_counted_once_per_actor() {
|
||||||
|
let appearances = vec![
|
||||||
|
appearance(1, "Actor A", 0, "movie_a"),
|
||||||
|
appearance(1, "Actor A", 0, "movie_a"),
|
||||||
|
];
|
||||||
|
let result = compute_top_actors(appearances);
|
||||||
|
assert_eq!(result[0].movie_count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_at_most_five() {
|
||||||
|
let appearances: Vec<ActorAppearance> = (0..10)
|
||||||
|
.flat_map(|i| {
|
||||||
|
(0..=i)
|
||||||
|
.map(move |m| appearance(i as u64, &format!("Actor {i}"), 0, &format!("movie_{m}")))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let result = compute_top_actors(appearances);
|
||||||
|
assert_eq!(result.len(), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tier_weights_applied_correctly() {
|
||||||
|
let appearances = vec![
|
||||||
|
appearance(1, "Lead", 0, "m1"),
|
||||||
|
appearance(2, "Support", 5, "m1"),
|
||||||
|
appearance(2, "Support", 3, "m2"),
|
||||||
|
appearance(3, "Minor", 10, "m1"),
|
||||||
|
];
|
||||||
|
let result = compute_top_actors(appearances);
|
||||||
|
assert_eq!(result[0].name, "Support");
|
||||||
|
assert!((result[0].score - 1.4).abs() < 0.001);
|
||||||
|
assert_eq!(result[0].movie_count, 2);
|
||||||
|
assert_eq!(result[1].name, "Lead");
|
||||||
|
assert!((result[1].score - 1.0).abs() < 0.001);
|
||||||
|
assert_eq!(result[2].name, "Minor");
|
||||||
|
assert!((result[2].score - 0.2).abs() < 0.001);
|
||||||
|
}
|
||||||
@@ -243,6 +243,7 @@ impl StatsRepository for FakeStatsRepository {
|
|||||||
top_genres: vec![],
|
top_genres: vec![],
|
||||||
rating_distribution: [0; 5],
|
rating_distribution: [0; 5],
|
||||||
watch_medium_distribution: vec![],
|
watch_medium_distribution: vec![],
|
||||||
|
top_actors: vec![],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ use crate::{
|
|||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
use api_types::{
|
use api_types::{
|
||||||
DiaryResponse, DirectorStatDto, GenreStatDto, MonthActivityDto, MonthlyRatingDto,
|
ActorStatDto, DiaryResponse, DirectorStatDto, GenreStatDto, MonthActivityDto, MonthlyRatingDto,
|
||||||
ProfileResponse, UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto,
|
ProfileResponse, UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto,
|
||||||
UserTrendsDto, UsersResponse, WatchMediumStatDto,
|
UserTrendsDto, UsersResponse, WatchMediumStatDto,
|
||||||
};
|
};
|
||||||
@@ -427,6 +427,16 @@ fn trends_to_dto(t: domain::models::UserTrends) -> UserTrendsDto {
|
|||||||
count: m.count,
|
count: m.count,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
|
top_actors: t
|
||||||
|
.top_actors
|
||||||
|
.into_iter()
|
||||||
|
.map(|a| ActorStatDto {
|
||||||
|
name: a.name,
|
||||||
|
profile_path: a.profile_path,
|
||||||
|
movie_count: a.movie_count,
|
||||||
|
score: a.score,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { WATCH_MEDIUMS } from "@/lib/watch-mediums"
|
|||||||
import { ReviewDetailSheet } from "@/components/review-detail-sheet"
|
import { ReviewDetailSheet } from "@/components/review-detail-sheet"
|
||||||
import { DiaryCalendar } from "@/components/diary-calendar"
|
import { DiaryCalendar } from "@/components/diary-calendar"
|
||||||
import type { DiaryEntryDto } from "@/lib/api/common"
|
import type { DiaryEntryDto } from "@/lib/api/common"
|
||||||
|
import { tmdbProfileUrl } from "@/lib/api/client"
|
||||||
import type { UserProfileResponse } from "@/features/users"
|
import type { UserProfileResponse } from "@/features/users"
|
||||||
|
|
||||||
type ProfileViewProps = {
|
type ProfileViewProps = {
|
||||||
@@ -231,6 +232,7 @@ function TrendsView({
|
|||||||
data: {
|
data: {
|
||||||
trends?: {
|
trends?: {
|
||||||
top_directors: { director: string; count: number }[]
|
top_directors: { director: string; count: number }[]
|
||||||
|
top_actors?: { name: string; profile_path?: string; movie_count: number; score: number }[]
|
||||||
monthly_ratings: {
|
monthly_ratings: {
|
||||||
month_label: string
|
month_label: string
|
||||||
avg_rating: number
|
avg_rating: number
|
||||||
@@ -269,6 +271,33 @@ function TrendsView({
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{data.trends.top_actors && data.trends.top_actors.length > 0 && (
|
||||||
|
<Card size="sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">{t("profile.topActors")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{data.trends.top_actors.map((a) => (
|
||||||
|
<div
|
||||||
|
key={a.name}
|
||||||
|
className="flex items-center gap-3 py-1.5"
|
||||||
|
>
|
||||||
|
<Avatar size="sm">
|
||||||
|
{a.profile_path && (
|
||||||
|
<AvatarImage src={tmdbProfileUrl(a.profile_path)} />
|
||||||
|
)}
|
||||||
|
<AvatarFallback>{a.name[0]}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="flex-1 truncate text-sm">{a.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t("common.films", { count: a.movie_count })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{data.trends.top_genres.length > 0 && (
|
{data.trends.top_genres.length > 0 && (
|
||||||
<Card size="sm">
|
<Card size="sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ export const directorStatDtoSchema = z.object({
|
|||||||
})
|
})
|
||||||
export type DirectorStatDto = z.infer<typeof directorStatDtoSchema>
|
export type DirectorStatDto = z.infer<typeof directorStatDtoSchema>
|
||||||
|
|
||||||
|
export const actorStatDtoSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
profile_path: z.string().optional(),
|
||||||
|
movie_count: z.number(),
|
||||||
|
score: z.number(),
|
||||||
|
})
|
||||||
|
export type ActorStatDto = z.infer<typeof actorStatDtoSchema>
|
||||||
|
|
||||||
export const genreStatDtoSchema = z.object({
|
export const genreStatDtoSchema = z.object({
|
||||||
genre: z.string(),
|
genre: z.string(),
|
||||||
count: z.number(),
|
count: z.number(),
|
||||||
@@ -64,6 +72,7 @@ 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_actors: z.array(actorStatDtoSchema).default([]),
|
||||||
top_genres: z.array(genreStatDtoSchema).default([]),
|
top_genres: z.array(genreStatDtoSchema).default([]),
|
||||||
rating_distribution: z.array(z.number()).default([]),
|
rating_distribution: z.array(z.number()).default([]),
|
||||||
watch_medium_distribution: z.array(watchMediumStatDtoSchema).default([]),
|
watch_medium_distribution: z.array(watchMediumStatDtoSchema).default([]),
|
||||||
|
|||||||
@@ -125,6 +125,7 @@
|
|||||||
"noEntries": "No entries",
|
"noEntries": "No entries",
|
||||||
"noTrends": "No trends yet",
|
"noTrends": "No trends yet",
|
||||||
"topDirectors": "Top Directors",
|
"topDirectors": "Top Directors",
|
||||||
|
"topActors": "Favorite Actors",
|
||||||
"monthlyActivity": "Monthly Activity",
|
"monthlyActivity": "Monthly Activity",
|
||||||
"searchPlaceholder": "Search entries...",
|
"searchPlaceholder": "Search entries...",
|
||||||
"watchedAgo": "Watched {{when}}"
|
"watchedAgo": "Watched {{when}}"
|
||||||
|
|||||||
Reference in New Issue
Block a user