'use client' import { useCallback, useEffect, useState } from 'react' import { useRouter } from 'next/navigation' import { authFetch, getApiBase } from '../lib/auth' import PageHeading from '../ui/PageHeading' import './stats.css' type Breakdown = { name: string; minutes: number } type Day = { date: string; minutes: number } type Stats = { state: 'ready' | 'not_configured' | 'unlinked' is_admin: boolean days: number updated_at?: string summary: null | { minutes: number; movies: number; episodes: number; plays: number; current_streak: number; longest_streak: number; active_days: number } daily?: Day[] top_titles?: { title: string; type: string; minutes: number; plays: number }[] recent?: { id: string; title: string; series: string; episode?: string; type: string; minutes: number; played_at: string; client: string; method: string }[] clients?: Breakdown[] methods?: Breakdown[] requests: { total: number; movies: number; tv: number; pending: number; approved: number; declined: number; recent: { request_id: number; title: string; media_type: string; status: number }[] } } const number = (value: number) => value.toLocaleString(undefined, { maximumFractionDigits: 0 }) const dateLabel = (date: string) => new Date(date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' }) function ViewingChart({ daily }: { daily: Day[] }) { const [selected, setSelected] = useState(null) const bucket = daily.length > 100 ? 7 : daily.length > 35 ? 3 : 1 const bars: { start: string; end: string; minutes: number }[] = [] for (let i = 0; i < daily.length; i += bucket) { const group = daily.slice(i, i + bucket) bars.push({ start: group[0].date, end: group[group.length - 1].date, minutes: group.reduce((sum, day) => sum + day.minutes, 0) }) } const peak = Math.max(1, ...bars.map((bar) => bar.minutes)) const active = selected === null ? null : bars[selected] return (

Your viewing rhythm

{bucket === 1 ? 'Daily' : `${bucket}-day`} watch time · UTC

Minutes
{active ? `${dateLabel(active.start)}${active.end !== active.start ? ` – ${dateLabel(active.end)}` : ''} · ${number(active.minutes)} minutes` : 'Select a bar to explore your watch time.'}
{bars.map((bar, index) => )}
) } function BreakdownCard({ title, rows }: { title: string; rows: Breakdown[] }) { const total = rows.reduce((sum, row) => sum + row.minutes, 0) return

{title}

{rows.length ?
{rows.map((row) =>
{row.name}{number(row.minutes)} min
)}
:

Your next watch will start the story here.

}
} export default function InsightsPage() { const router = useRouter() const [days, setDays] = useState(30) const [data, setData] = useState(null) const [busy, setBusy] = useState(true) const [error, setError] = useState('') const [revision, setRevision] = useState(0) const load = useCallback(async (signal: AbortSignal) => { setBusy(true) setError('') setData(null) try { const response = await authFetch(`${getApiBase()}/insights?days=${days}`, { signal }) if (response.status === 401) { router.replace('/login?next=%2Finsights'); return } if (response.status === 403) throw new Error('Your account cannot access viewing stats. Please contact an administrator.') if (!response.ok) { const result = await response.json().catch(() => ({})) throw new Error(typeof result.detail === 'string' ? result.detail : 'Your viewing stats are temporarily unavailable. Please try again shortly.') } const result = await response.json() as Stats if (!signal.aborted) setData(result) } catch (err) { if (!signal.aborted) setError(err instanceof Error ? err.message : 'Could not load your stats.') } finally { if (!signal.aborted) setBusy(false) } }, [days, router]) useEffect(() => { const controller = new AbortController() void load(controller.signal) return () => controller.abort() }, [load, revision]) const summary = data?.summary return (
setRevision((value) => value + 1)}>{busy ? 'Loading…' : 'Refresh stats'}} />
Stats period{[7, 30, 90, 365].map((value) => )}

From Jellystat{data?.updated_at && · Updated {new Date(data.updated_at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}}

{busy &&

Gathering your stats

Fetching your viewing history from Jellystat.

} {error &&

Stats couldn’t load

{error}

} {data?.state === 'not_configured' &&

Your viewing story starts here

{data.is_admin ? 'Connect your Jellystat instance to bring personal viewing stats into Magent.' : 'Viewing stats will appear here once your administrator connects Jellystat.'}

{data.is_admin && Connect Jellystat}
} {data?.state === 'unlinked' &&

Link your viewing account

Your Magent account needs a Jellyfin identity to find your stats. Sign in using Jellyfin, or ask your administrator to sync Jellyfin users.

} {summary && <>
Minutes watched{number(summary.minutes)}{number(summary.minutes / 60)} hours across {number(summary.plays)} plays
Movies played{number(summary.movies)}Different movies you pressed play on
Episodes played{number(summary.episodes)}Different episodes in your history
Requests made{number(data.requests.total)}{number(data.requests.movies)} movies · {number(data.requests.tv)} TV requests
{summary.plays === 0 &&
No viewing history in this period yet. Try a longer period, or come back after your next watch.
}

A little watch history

{summary.current_streak} days
Current streak

Consecutive viewing days through today or yesterday.

{summary.longest_streak} days
Longest run

Your best streak in this period.

{summary.active_days} days
Time for a story

Days with at least a minute watched.

Most watched

By minutes
{data.top_titles?.length ?
    {data.top_titles.map((title, index) =>
  1. {String(index + 1).padStart(2, '0')}
    {title.title}{title.type === 'series' ? 'TV series' : title.type === 'movie' ? 'Movie' : 'Other media'} · {title.plays} plays
    {number(title.minutes)}min
  2. )}
:

Your favourites will find their place here.

}
} {data &&
{summary &&

Recently watched

Latest 20 plays
{data.recent?.length ?
{data.recent.map((play) =>
{play.series || play.title}{play.series ? `${play.episode} · ${play.title}` : play.client}{play.client} · {play.method}
{number(play.minutes)} min
)}
:

Plays recorded by Jellystat will appear here.

}
}

Your requests

View all
{data.requests.total}submitted in the past {days} days
{data.requests.pending} Pending{data.requests.approved} Approved{data.requests.declined} Declined
{data.requests.recent.length > 0 ? :

Something on your watchlist? Make a request.

}
} {summary &&

Private to your account · Stats come from Jellystat and may take a minute to refresh. Counts describe plays, including unfinished watches. Movies are identified from Jellystat’s movie libraries; other media still contributes to watch time. Charts and streaks use UTC and the activity dates recorded by Jellystat.

}
) }