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:
@@ -9,11 +9,13 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { MovieCard } from "@/components/movie-card"
|
||||
import { RatingHistogram } from "@/components/rating-histogram"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { SwipeTabs } from "@/components/swipe-tabs"
|
||||
import { VirtualList } from "@/components/virtual-list"
|
||||
import { useInfiniteDiary } from "@/features/diary"
|
||||
import { TimeAgo } from "@/components/time-ago"
|
||||
import { WATCH_MEDIUMS } from "@/lib/watch-mediums"
|
||||
import type { UserProfileResponse } from "@/features/users"
|
||||
|
||||
type ProfileViewProps = {
|
||||
@@ -191,6 +193,9 @@ function TrendsView({
|
||||
avg_rating: number
|
||||
count: number
|
||||
}[]
|
||||
top_genres: { genre: string; count: number }[]
|
||||
rating_distribution: number[]
|
||||
watch_medium_distribution: { medium: string; count: number }[]
|
||||
}
|
||||
}
|
||||
}) {
|
||||
@@ -200,7 +205,7 @@ function TrendsView({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{data.trends.top_directors.length > 0 && (
|
||||
{data.trends.top_directors.some((d) => d.count >= 2) && (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">{t("profile.topDirectors")}</CardTitle>
|
||||
@@ -221,6 +226,56 @@ function TrendsView({
|
||||
</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 && (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
|
||||
@@ -48,10 +48,25 @@ export const directorStatDtoSchema = z.object({
|
||||
})
|
||||
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({
|
||||
monthly_ratings: z.array(monthlyRatingDtoSchema),
|
||||
top_directors: z.array(directorStatDtoSchema),
|
||||
max_director_count: z.number(),
|
||||
top_genres: z.array(genreStatDtoSchema).default([]),
|
||||
rating_distribution: z.array(z.number()).default([]),
|
||||
watch_medium_distribution: z.array(watchMediumStatDtoSchema).default([]),
|
||||
})
|
||||
export type UserTrendsDto = z.infer<typeof userTrendsDtoSchema>
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ export type WrapUpReport = {
|
||||
lowest_rated_movie?: MovieRef
|
||||
first_movie_of_period?: MovieRef
|
||||
last_movie_of_period?: MovieRef
|
||||
watch_medium_distribution: { medium: string; count: number }[]
|
||||
poster_paths: string[]
|
||||
top_cast_profile_paths: string[]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Bookmark, BookmarkCheck, Star, User } from "lucide-react"
|
||||
import { BackButton } from "@/components/back-button"
|
||||
import { CommunityReviews } from "@/components/community-reviews"
|
||||
import { ViewingHistory } from "@/components/viewing-history"
|
||||
import { StarDisplay } from "@/components/star-display"
|
||||
import { RatingHistogram } from "@/components/rating-histogram"
|
||||
import { HorizontalStrip } from "@/components/horizontal-strip"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
@@ -16,6 +16,7 @@ import { FunFacts } from "@/components/wrapup-fun-facts"
|
||||
import { RankCard } from "@/components/wrapup-rank-card"
|
||||
import { posterUrl } from "@/lib/api/client"
|
||||
import { fmtUsd } from "@/lib/format"
|
||||
import { WATCH_MEDIUMS } from "@/lib/watch-mediums"
|
||||
import { useWrapUpReport } from "@/features/wrapup"
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||
import type { MovieRef } from "@/features/wrapup"
|
||||
@@ -144,6 +145,34 @@ function WrapUpReportPage() {
|
||||
</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 */}
|
||||
{report.movies_per_month.length > 0 && (
|
||||
<RevealCard>
|
||||
|
||||
Reference in New Issue
Block a user