387 lines
14 KiB
TypeScript
387 lines
14 KiB
TypeScript
import { useMemo } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { motion } from 'framer-motion'
|
|
import { Clock, Flame, Film, Tv, Award, Activity, CalendarStar } from '../lib/icons'
|
|
import { useLibraryItems } from '../hooks/use-jellyfin'
|
|
import { useDiary } from '../stores/diary-store'
|
|
import { genreBreakdown, totalHoursWatched, longestBinge, watchStreak } from '../lib/watch-stats'
|
|
import {
|
|
hoursPerGenre,
|
|
hoursPerStudio,
|
|
completion,
|
|
topByPersonRole,
|
|
totalTimeSavedSeconds,
|
|
} from '../lib/stats'
|
|
import { formatTimeSaved } from '../lib/time-saved'
|
|
|
|
const CURRENT_YEAR = new Date().getFullYear()
|
|
|
|
/**
|
|
* Detailed stats / year-in-watching. Profile page surfaces the at-a-glance
|
|
* summary; this one digs deeper into hours-weighted breakdowns, top
|
|
* directors / actors, completion ratios, and the time-saved-by-skipping
|
|
* total across every series.
|
|
*/
|
|
export default function StatsPage() {
|
|
const navigate = useNavigate()
|
|
const { data, isLoading } = useLibraryItems(undefined, {
|
|
includeItemTypes: ['Movie', 'Series', 'Episode'],
|
|
sortBy: ['DatePlayed'],
|
|
sortOrder: ['Descending'],
|
|
filters: ['IsPlayed'],
|
|
limit: 2000,
|
|
includePeople: true,
|
|
})
|
|
const items = useMemo(() => data?.Items || [], [data?.Items])
|
|
|
|
const yearItems = useMemo(
|
|
() =>
|
|
items.filter(it => {
|
|
const raw = it.UserData?.LastPlayedDate
|
|
if (!raw) return false
|
|
return new Date(raw).getFullYear() === CURRENT_YEAR
|
|
}),
|
|
[items],
|
|
)
|
|
|
|
const totalHoursAll = useMemo(() => totalHoursWatched(items), [items])
|
|
const totalHoursYear = useMemo(() => totalHoursWatched(yearItems), [yearItems])
|
|
const streak = useMemo(() => watchStreak(items), [items])
|
|
const binge = useMemo(() => longestBinge(items), [items])
|
|
const genreShare = useMemo(() => genreBreakdown(items).slice(0, 10), [items])
|
|
const hoursGenre = useMemo(() => hoursPerGenre(items).slice(0, 10), [items])
|
|
const hoursStudio = useMemo(() => hoursPerStudio(items).slice(0, 8), [items])
|
|
const comp = useMemo(() => completion(items), [items])
|
|
const topDirectors = useMemo(
|
|
() => topByPersonRole(items, (_role, type) => type === 'Director', 8),
|
|
[items],
|
|
)
|
|
const topActors = useMemo(
|
|
() => topByPersonRole(items, (_role, type) => type === 'Actor', 10),
|
|
[items],
|
|
)
|
|
const timeSaved = useMemo(() => totalTimeSavedSeconds(), [])
|
|
const diary = useDiary(s => s.entries)
|
|
const diaryYearCount = useMemo(
|
|
() =>
|
|
diary.filter(d => {
|
|
const t = Date.parse(d.watchedAt)
|
|
return Number.isFinite(t) && new Date(t).getFullYear() === CURRENT_YEAR
|
|
}).length,
|
|
[diary],
|
|
)
|
|
|
|
const moviesCount = useMemo(() => items.filter(i => i.Type === 'Movie').length, [items])
|
|
const episodesCount = useMemo(() => items.filter(i => i.Type === 'Episode').length, [items])
|
|
|
|
return (
|
|
<div className="px-7 pb-12 pt-6">
|
|
<header className="mb-8">
|
|
<p className="text-[11px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-1.5">
|
|
Stats
|
|
</p>
|
|
<h1 className="font-display text-3xl font-bold tracking-tight text-text-1 leading-tight">
|
|
Your year in watching
|
|
</h1>
|
|
<p className="text-[13px] text-text-3 mt-1.5">
|
|
A deeper look at everything you've played - {items.length.toLocaleString()} {items.length === 1 ? 'item' : 'items'} tracked.
|
|
</p>
|
|
</header>
|
|
|
|
{isLoading && items.length === 0 && (
|
|
<div className="rounded-xl bg-elevated/30 border border-border p-8 text-center mb-8">
|
|
<p className="text-[13px] text-text-2 font-medium">Crunching the numbers...</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Headline tiles */}
|
|
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3 mb-10">
|
|
<StatTile
|
|
label={`Hours in ${CURRENT_YEAR}`}
|
|
value={totalHoursYear.toFixed(0)}
|
|
unit="hours"
|
|
icon={<Clock size={14} className="text-accent" />}
|
|
hint={`${totalHoursAll.toFixed(0)} all-time`}
|
|
accent
|
|
/>
|
|
<StatTile
|
|
label="Movies played"
|
|
value={moviesCount.toString()}
|
|
icon={<Film size={14} className="text-accent" />}
|
|
/>
|
|
<StatTile
|
|
label="Episodes played"
|
|
value={episodesCount.toString()}
|
|
icon={<Tv size={14} className="text-accent" />}
|
|
/>
|
|
<StatTile
|
|
label="Time saved skipping"
|
|
value={formatTimeSaved(timeSaved.total)}
|
|
icon={<Activity size={14} className="text-accent" />}
|
|
hint={timeSaved.series > 0 ? `across ${timeSaved.series} ${timeSaved.series === 1 ? 'show' : 'shows'}` : 'no skips yet'}
|
|
/>
|
|
<StatTile
|
|
label="Current streak"
|
|
value={String(streak.current)}
|
|
unit={streak.current === 1 ? 'day' : 'days'}
|
|
icon={<Flame size={14} className="text-accent" />}
|
|
hint={streak.longest > streak.current ? `Best: ${streak.longest}` : 'Keep going'}
|
|
/>
|
|
<StatTile
|
|
label="Diary entries"
|
|
value={String(diaryYearCount)}
|
|
unit={diaryYearCount === 1 ? `entry in ${CURRENT_YEAR}` : `entries in ${CURRENT_YEAR}`}
|
|
icon={<CalendarStar size={14} className="text-accent" />}
|
|
/>
|
|
</div>
|
|
|
|
{/* Two-column: hours per genre + hours per studio */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-10">
|
|
<Section title="Hours by genre" empty={hoursGenre.length === 0 ? 'No genre runtime data yet.' : null}>
|
|
<div className="space-y-2">
|
|
{hoursGenre.map((g, i) => (
|
|
<Bar key={g.label} label={g.label} share={g.share} index={i} suffix={`${g.hours.toFixed(0)}h`} />
|
|
))}
|
|
</div>
|
|
</Section>
|
|
<Section title="Top studios" empty={hoursStudio.length === 0 ? 'No studio info on your library.' : null}>
|
|
<div className="space-y-2">
|
|
{hoursStudio.map((s, i) => (
|
|
<Bar key={s.label} label={s.label} share={s.share} index={i} suffix={`${s.hours.toFixed(0)}h`} />
|
|
))}
|
|
</div>
|
|
</Section>
|
|
</div>
|
|
|
|
{/* Completion ratios */}
|
|
<Section title="Completion" className="mb-10">
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<CompletionTile label="Finished" value={comp.completed} total={comp.total} accent />
|
|
<CompletionTile label="In progress" value={comp.inProgress} total={comp.total} />
|
|
<CompletionTile label="Untouched" value={comp.unstarted} total={comp.total} />
|
|
</div>
|
|
<p className="mt-3 text-[12px] text-text-3 tabular-nums">
|
|
{(comp.completionRate * 100).toFixed(1)}% of tracked items reached the finish line.
|
|
</p>
|
|
</Section>
|
|
|
|
{/* People */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-10">
|
|
<Section title="Most-watched directors" empty={topDirectors.length === 0 ? 'No director credits on items.' : null}>
|
|
<PersonList rows={topDirectors} onClick={n => navigate(`/search?q=${encodeURIComponent(n)}`)} />
|
|
</Section>
|
|
<Section title="Most-seen actors" empty={topActors.length === 0 ? 'No cast info on items.' : null}>
|
|
<PersonList rows={topActors} onClick={n => navigate(`/search?q=${encodeURIComponent(n)}`)} />
|
|
</Section>
|
|
</div>
|
|
|
|
{/* Side note: longest binge + breakdown */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-10">
|
|
<Section title="Longest binge">
|
|
{binge.count > 0 ? (
|
|
<div>
|
|
<p className="text-[28px] font-display font-bold text-text-1 tabular-nums leading-none">
|
|
{binge.count} <span className="text-[14px] font-medium text-text-3">items in one day</span>
|
|
</p>
|
|
<p className="text-[12.5px] text-text-3 mt-2">
|
|
On {binge.day ? new Date(binge.day + 'T00:00:00').toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) : 'unknown'}.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<p className="text-[12.5px] text-text-3">No watched items yet.</p>
|
|
)}
|
|
</Section>
|
|
<Section title="Genre share (by count)" empty={genreShare.length === 0 ? null : null}>
|
|
<div className="space-y-2">
|
|
{genreShare.map((g, i) => (
|
|
<Bar key={g.genre} label={g.genre} share={g.share} index={i} suffix={`${g.count}`} />
|
|
))}
|
|
</div>
|
|
</Section>
|
|
</div>
|
|
|
|
{/* Time saved breakdown */}
|
|
<Section title="Time saved" className="mb-10">
|
|
{timeSaved.total > 0 ? (
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
|
<BadgeTile label="Total" value={formatTimeSaved(timeSaved.total)} icon={<Award size={14} className="text-accent" />} />
|
|
<BadgeTile label="Intros skipped" value={formatTimeSaved(timeSaved.intros)} />
|
|
<BadgeTile label="Credits skipped" value={formatTimeSaved(timeSaved.credits)} />
|
|
</div>
|
|
) : (
|
|
<p className="text-[12.5px] text-text-3">No auto-skips recorded yet. Turn intro / credits skipping on in Playback settings.</p>
|
|
)}
|
|
</Section>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function StatTile({
|
|
label,
|
|
value,
|
|
unit,
|
|
hint,
|
|
icon,
|
|
accent,
|
|
}: {
|
|
label: string
|
|
value: string
|
|
unit?: string
|
|
hint?: string
|
|
icon?: React.ReactNode
|
|
accent?: boolean
|
|
}) {
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 6 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
|
className={`rounded-xl p-4 ring-1 ${
|
|
accent ? 'bg-accent/10 ring-accent/30' : 'bg-elevated/40 ring-border'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2 text-[10.5px] uppercase tracking-[0.16em] font-semibold text-text-3 mb-2">
|
|
{icon}
|
|
{label}
|
|
</div>
|
|
<div className="flex items-baseline gap-1.5">
|
|
<span className="font-display text-[26px] font-bold text-text-1 tabular-nums leading-none">
|
|
{value}
|
|
</span>
|
|
{unit && <span className="text-[12px] text-text-3 tracking-tight">{unit}</span>}
|
|
</div>
|
|
{hint && <p className="text-[11px] text-text-4 mt-1.5 tracking-tight">{hint}</p>}
|
|
</motion.div>
|
|
)
|
|
}
|
|
|
|
function Section({
|
|
title,
|
|
children,
|
|
empty,
|
|
className,
|
|
}: {
|
|
title: string
|
|
children: React.ReactNode
|
|
empty?: string | null
|
|
className?: string
|
|
}) {
|
|
return (
|
|
<section className={`rounded-xl bg-elevated/30 border border-border p-5 ${className || ''}`}>
|
|
<h2 className="text-[13px] font-semibold text-text-1 mb-3 tracking-tight">{title}</h2>
|
|
{empty ? <p className="text-[12.5px] text-text-3">{empty}</p> : children}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function Bar({
|
|
label,
|
|
share,
|
|
index,
|
|
suffix,
|
|
}: {
|
|
label: string
|
|
share: number
|
|
index: number
|
|
suffix?: string
|
|
}) {
|
|
const pct = Math.max(2, Math.round(share * 100))
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, x: -6 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{ duration: 0.3, delay: Math.min(index * 0.04, 0.4) }}
|
|
className="flex items-center gap-3 text-[12px]"
|
|
>
|
|
<span className="w-32 shrink-0 text-text-2 font-medium tracking-tight truncate">
|
|
{label}
|
|
</span>
|
|
<div className="flex-1 h-2.5 rounded-full bg-elevated/60 overflow-hidden">
|
|
<motion.div
|
|
initial={{ width: 0 }}
|
|
animate={{ width: `${pct}%` }}
|
|
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
|
className="h-full bg-accent"
|
|
/>
|
|
</div>
|
|
<span className="w-16 shrink-0 text-right text-text-3 tabular-nums">
|
|
{suffix ?? `${pct}%`}
|
|
</span>
|
|
</motion.div>
|
|
)
|
|
}
|
|
|
|
function CompletionTile({
|
|
label,
|
|
value,
|
|
total,
|
|
accent,
|
|
}: {
|
|
label: string
|
|
value: number
|
|
total: number
|
|
accent?: boolean
|
|
}) {
|
|
const pct = total > 0 ? Math.round((value / total) * 100) : 0
|
|
return (
|
|
<div className={`rounded-lg p-4 ring-1 ${accent ? 'bg-accent/8 ring-accent/25' : 'bg-elevated/40 ring-border'}`}>
|
|
<p className="text-[10.5px] uppercase tracking-[0.16em] font-semibold text-text-3 mb-1.5">{label}</p>
|
|
<p className="font-display text-[22px] font-bold text-text-1 tabular-nums leading-none">
|
|
{value.toLocaleString()}
|
|
</p>
|
|
<p className="text-[11px] text-text-4 mt-1 tabular-nums">{pct}%</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PersonList({
|
|
rows,
|
|
onClick,
|
|
}: {
|
|
rows: { name: string; count: number }[]
|
|
onClick: (name: string) => void
|
|
}) {
|
|
return (
|
|
<ol className="space-y-1.5">
|
|
{rows.map((p, i) => (
|
|
<li key={p.name}>
|
|
<button
|
|
onClick={() => onClick(p.name)}
|
|
className="w-full flex items-center gap-3 px-2 py-1.5 rounded-md hover:bg-white/4 transition-colors text-left focus-ring"
|
|
>
|
|
<span className="w-6 h-6 grid place-items-center rounded-full bg-accent/15 text-accent text-[10.5px] font-bold tabular-nums shrink-0">
|
|
{i + 1}
|
|
</span>
|
|
<span className="flex-1 text-[12.5px] text-text-1 font-medium truncate tracking-tight">
|
|
{p.name}
|
|
</span>
|
|
<span className="text-[11px] text-text-3 tabular-nums">
|
|
{p.count} {p.count === 1 ? 'item' : 'items'}
|
|
</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
)
|
|
}
|
|
|
|
function BadgeTile({
|
|
label,
|
|
value,
|
|
icon,
|
|
}: {
|
|
label: string
|
|
value: string
|
|
icon?: React.ReactNode
|
|
}) {
|
|
return (
|
|
<div className="rounded-lg p-3.5 ring-1 ring-border bg-elevated/40">
|
|
<p className="text-[10.5px] uppercase tracking-[0.16em] font-semibold text-text-3 mb-1.5 flex items-center gap-1.5">
|
|
{icon}
|
|
{label}
|
|
</p>
|
|
<p className="font-display text-[20px] font-bold text-text-1 tabular-nums leading-none">{value}</p>
|
|
</div>
|
|
)
|
|
}
|