main pages

This commit is contained in:
2026-03-30 13:40:42 +03:00
parent 3be784d675
commit 430981cbf7
19 changed files with 6531 additions and 0 deletions
+386
View File
@@ -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>
)
}