main pages
This commit is contained in:
@@ -0,0 +1,386 @@
|
||||
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">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user