'use client' import { useCallback, useEffect, useRef, useState } from 'react' import { useRouter } from 'next/navigation' import { authFetch, getApiBase } from '../../lib/auth' import PageHeading from '../../ui/PageHeading' import { type Stats, BreakdownCard, RecentArtwork, StatsNavigation, StreamingCard, ViewingChart, dateLabel, number } from '../components' import '../stats.css' import './reports.css' type Change = { current: number; previous: number; difference: number; percent: number | null } type MonthlyReport = Omit & { month: string; available_months: string[]; is_partial: boolean; comparison_capped: boolean period_start: string; period_end: string; comparison_month: string; comparison_start: string; comparison_end: string previous_summary?: Stats['summary']; previous_requests?: Omit changes?: Record<'minutes' | 'movies' | 'episodes' | 'plays' | 'active_days' | 'longest_streak' | 'requests', Change> } const monthLabel = (month: string, short = false) => new Date(`${month}-01T00:00:00Z`).toLocaleDateString(undefined, { month: short ? 'short' : 'long', year: 'numeric', timeZone: 'UTC' }) const decimal = (value: number) => value.toLocaleString(undefined, { maximumFractionDigits: 1 }) function ChangeLabel({ change, unit = '' }: { change: Change; unit?: string }) { const delta = change.difference return
0 ? 'is-up' : delta < 0 ? 'is-down' : 'is-flat'}`}> {delta === 0 ? 'No change' : `${delta > 0 ? '+' : '−'}${decimal(Math.abs(delta))}${unit}${change.percent === null ? '' : ` (${delta > 0 ? '+' : '−'}${decimal(Math.abs(change.percent))}%)`}`} {change.percent === null ? 'No activity recorded in the comparison period' : `Previously ${decimal(change.previous)}${unit}`}
} export default function MonthlyReportsPage() { const router = useRouter() const [month, setMonth] = useState('') const [monthReady, setMonthReady] = useState(false) const [months, setMonths] = useState([]) const [data, setData] = useState(null) const [busy, setBusy] = useState(true) const [error, setError] = useState('') const [revision, setRevision] = useState(0) const [downloading, setDownloading] = useState(false) const [downloadError, setDownloadError] = useState('') const downloadController = useRef(null) useEffect(() => { setMonth(new URLSearchParams(window.location.search).get('month') || '') setMonthReady(true) }, []) useEffect(() => { if (monthReady) window.history.replaceState(null, '', `/insights/reports${month ? `?month=${encodeURIComponent(month)}` : ''}`) }, [month, monthReady]) useEffect(() => () => downloadController.current?.abort(), []) const load = useCallback(async (signal: AbortSignal) => { setBusy(true) setError('') setData(null) setDownloadError('') try { const query = month ? `?month=${encodeURIComponent(month)}` : '' const response = await authFetch(`${getApiBase()}/insights/reports/monthly${query}`, { signal }) if (response.status === 401) { router.replace(`/login?next=${encodeURIComponent(`/insights/reports${query}`)}`); return } if (response.status === 403) throw new Error('Your account cannot access viewing reports. Please contact an administrator.') if (!response.ok) { const result = await response.json().catch(() => ({})) throw new Error(typeof result.detail === 'string' ? result.detail : 'Your report is temporarily unavailable. Please try again shortly.') } const result = await response.json() as MonthlyReport if (!signal.aborted) { setData(result); setMonths(result.available_months) } } catch (err) { if (!signal.aborted) setError(err instanceof Error ? err.message : 'Could not load your report.') } finally { if (!signal.aborted) setBusy(false) } }, [month, router]) useEffect(() => { if (!monthReady) return const controller = new AbortController() void load(controller.signal) return () => controller.abort() }, [load, revision, monthReady]) const download = async () => { if (data?.state !== 'ready' || downloading) return const selected = data.month const controller = new AbortController() downloadController.current = controller setDownloading(true) setDownloadError('') try { const response = await authFetch(`${getApiBase()}/insights/reports/monthly.csv?month=${selected}`, { signal: controller.signal }) if (response.status === 401) { router.replace(`/login?next=${encodeURIComponent(`/insights/reports?month=${selected}`)}`); return } if (!response.ok) throw new Error('The report could not be downloaded. Please try again.') const blob = await response.blob() if (controller.signal.aborted) return const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = `magent-monthly-report-${selected}.csv` document.body.appendChild(link) link.click() link.remove() window.setTimeout(() => URL.revokeObjectURL(url), 1000) } catch (err) { if (!controller.signal.aborted) setDownloadError(err instanceof Error ? err.message : 'Could not download your report.') } finally { if (!controller.signal.aborted) setDownloading(false) } } const selectedMonth = month || data?.month || '' const monthIndex = months.indexOf(selectedMonth) const summary = data?.summary const changes = data?.changes return
} />

From Jellystat · UTC

{downloadError &&

{downloadError}

} {busy &&

Putting your month together

Gathering your viewing history and the previous month’s comparison.

} {error &&

Report couldn’t load

{error}

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

Your monthly story starts here

{data.is_admin ? 'Connect Jellystat to bring your monthly viewing reports into Magent.' : 'Monthly reports will appear once your administrator connects Jellystat.'}

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

Link your viewing account

Your report needs your Jellyfin account link. Sign in using Jellyfin, or ask your administrator to review your user identities.

{data.is_admin && Review user identities}
} {data && summary && changes && <>
{data.is_partial ? 'Month to date' : 'Your monthly recap'}

{monthLabel(data.month)}

{data.is_partial ? `Compared with the same elapsed time in ${monthLabel(data.comparison_month)}${data.comparison_capped ? ', capped at the end of that month' : ''}.` : `Compared with ${monthLabel(data.comparison_month)}.`}

{data.is_partial ? 'In progress' : 'Complete month'}{data.updated_at && `Updated ${new Date(data.updated_at).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' })} UTC`}
Minutes watched{number(summary.minutes)}{decimal(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)}{data.requests.movies} movies · {data.requests.tv} TV requests
{summary.plays === 0 &&
No viewing history was recorded for this month. Your request totals and comparison are still shown.
}

Your viewing habits

{summary.active_days} days
Days you watched

At least one minute of viewing.

{summary.longest_streak} days
Longest run

Consecutive viewing days this month.

{data.daily?.length ? number(summary.minutes / data.daily.length) : 0} min
Daily average

Across the calendar days in this report.

Most watched

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

Your most watched titles will appear here.

}

A look back

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

Plays recorded during this month will appear here.

}

Your requests

View all
{data.requests.total}submitted in {monthLabel(data.month, true)}
{data.requests.pending} Pending{data.requests.approved} Approved{data.requests.declined} Declined
{data.requests.recent.length ? :

No requests recorded during this month.

}

Statuses reflect where these requests are now.

Private to your account · Based on history retained by Jellystat and requests available in Magent. Plays include unfinished watches. Dates and streaks use UTC. Reports may take a minute to refresh; historical totals can change when retained history or library metadata changes.

}
}