Files
Magent/frontend/app/insights/reports/page.tsx
T
Assclaw b286ca3c42
Magent CI/CD / verify (push) Successful in 11m6s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m40s
Let users email personal reports on demand
2026-09-10 16:15:39 +12:00

167 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import EmailReportControl from './EmailReportControl'
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<Stats, 'days'> & {
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<Stats['requests'], 'recent'>
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 <div className={`report-change ${delta > 0 ? 'is-up' : delta < 0 ? 'is-down' : 'is-flat'}`}>
<span>{delta === 0 ? 'No change' : `${delta > 0 ? '+' : ''}${decimal(Math.abs(delta))}${unit}${change.percent === null ? '' : ` (${delta > 0 ? '+' : ''}${decimal(Math.abs(change.percent))}%)`}`}</span>
<small>{change.percent === null ? 'No activity recorded in the comparison period' : `Previously ${decimal(change.previous)}${unit}`}</small>
</div>
}
export default function MonthlyReportsPage() {
const router = useRouter()
const [month, setMonth] = useState('')
const [monthReady, setMonthReady] = useState(false)
const [months, setMonths] = useState<string[]>([])
const [data, setData] = useState<MonthlyReport | null>(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<AbortController | null>(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 <main className="stats-page reports-page">
<PageHeading title="Monthly report" description="Your month in viewing. See what you watched, what changed, and what you requested." actions={<>
<button className="ghost-button" type="button" disabled={busy || downloading} onClick={() => setRevision((value) => value + 1)}>Refresh report</button>
<button className="ghost-button" type="button" disabled={busy || downloading || data?.state !== 'ready'} onClick={() => void download()}>{downloading ? 'Downloading…' : 'Download CSV'}</button>
</>} />
<StatsNavigation reports />
<div className="stats-toolbar">
<div className="report-month-picker">
<button type="button" className="ghost-button" aria-label="Previous month" disabled={busy || downloading || monthIndex < 0 || monthIndex >= months.length - 1} onClick={() => setMonth(months[monthIndex + 1])}></button>
<label><span className="stats-sr-only">Report month</span><select value={selectedMonth} disabled={busy || downloading || !months.length} onChange={(event) => setMonth(event.target.value)}>{!selectedMonth && <option value="">Latest complete month</option>}{months.map((value, index) => <option value={value} key={value}>{monthLabel(value)}{index === 0 ? ' · month to date' : ''}</option>)}</select></label>
<button type="button" className="ghost-button" aria-label="Next month" disabled={busy || downloading || monthIndex <= 0} onClick={() => setMonth(months[monthIndex - 1])}></button>
</div>
<p className="stats-source"><span className={data?.state === 'ready' ? 'stats-source-dot is-ready' : 'stats-source-dot'} />From Jellystat · UTC</p>
</div>
{downloadError && <p className="stats-notice" role="alert">{downloadError}</p>}
{data?.state === 'ready' && !busy && <EmailReportControl month={data.month} />}
{busy && <div className="stats-state" role="status"><span className="stats-state-symbol" aria-hidden="true"></span><h2>Putting your month together</h2><p>Gathering your viewing history and the previous months comparison.</p></div>}
{error && <div className="stats-state" role="alert"><h2>Report couldnt load</h2><p>{error}</p><button type="button" className="ghost-button" onClick={() => setRevision((value) => value + 1)}>Try again</button>{month && <button type="button" className="ghost-button" onClick={() => setMonth('')}>Latest complete month</button>}</div>}
{data?.state === 'not_configured' && <section className="stats-state"><h2>Your monthly story starts here</h2><p>{data.is_admin ? 'Connect Jellystat to bring your monthly viewing reports into Magent.' : 'Monthly reports will appear once your administrator connects Jellystat.'}</p>{data.is_admin && <a className="stats-action" href="/admin/jellystat">Connect Jellystat</a>}</section>}
{data?.state === 'unlinked' && <section className="stats-state"><h2>Link your viewing account</h2><p>Your report needs your Jellyfin account link. Sign in using Jellyfin, or ask your administrator to review your user identities.</p>{data.is_admin && <a className="stats-action" href="/admin/identities">Review user identities</a>}</section>}
{data && summary && changes && <>
<section className="report-intro" aria-label="Report period">
<div><span className="report-kicker">{data.is_partial ? 'Month to date' : 'Your monthly recap'}</span><h2>{monthLabel(data.month)}</h2><p>{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)}.`}</p></div>
<div className="report-period-meta"><span>{data.is_partial ? 'In progress' : 'Complete month'}</span><small>{data.updated_at && `Updated ${new Date(data.updated_at).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' })} UTC`}</small></div>
</section>
<section className="stats-metrics" aria-label="Monthly totals">
<article className="stats-metric stats-metric-accent"><span>Minutes watched</span><strong>{number(summary.minutes)}</strong><small>{decimal(summary.minutes / 60)} hours across {number(summary.plays)} plays</small><ChangeLabel change={changes.minutes} unit=" min" /></article>
<article className="stats-metric"><span>Movies played</span><strong>{number(summary.movies)}</strong><small>Different movies you pressed play on</small><ChangeLabel change={changes.movies} /></article>
<article className="stats-metric"><span>Episodes played</span><strong>{number(summary.episodes)}</strong><small>Different episodes in your history</small><ChangeLabel change={changes.episodes} /></article>
<article className="stats-metric"><span>Requests made</span><strong>{number(data.requests.total)}</strong><small>{data.requests.movies} movies · {data.requests.tv} TV requests</small><ChangeLabel change={changes.requests} /></article>
</section>
{summary.plays === 0 && <div className="stats-notice" role="status">No viewing history was recorded for this month. Your request totals and comparison are still shown.</div>}
<div className="stats-main-grid">
<ViewingChart key={`${data.month}-${revision}`} daily={data.daily ?? []} />
<section className="stats-panel report-highlights"><div className="stats-panel-heading"><h2>Your viewing habits</h2></div>
<div className="stats-highlight"><span className="stats-highlight-number">{summary.active_days}<small> days</small></span><div><strong>Days you watched</strong><p>At least one minute of viewing.</p><ChangeLabel change={changes.active_days} unit=" days" /></div></div>
<div className="stats-highlight"><span className="stats-highlight-number">{summary.longest_streak}<small> days</small></span><div><strong>Longest run</strong><p>Consecutive viewing days this month.</p><ChangeLabel change={changes.longest_streak} unit=" days" /></div></div>
<div className="stats-highlight"><span className="stats-highlight-number">{data.daily?.length ? number(summary.minutes / data.daily.length) : 0}<small> min</small></span><div><strong>Daily average</strong><p>Across the calendar days in this report.</p></div></div>
</section>
</div>
<div className="stats-three-grid">
<section className="stats-panel"><div className="stats-panel-heading"><h2>Most watched</h2><span className="stats-unit">By minutes</span></div>{data.top_titles?.length ? <ol className="stats-top-titles report-top-titles">{data.top_titles.map((title, index) => <li key={`${title.title}-${index}`}><div><strong>{title.title}</strong><small>{title.type === 'series' ? 'TV series' : title.type === 'movie' ? 'Movie' : 'Other media'} · {title.plays} plays</small></div><span>{number(title.minutes)}<small>min</small></span></li>)}</ol> : <p className="stats-muted">Your most watched titles will appear here.</p>}</section>
<BreakdownCard title="Your players" rows={data.clients ?? []} />
<StreamingCard rows={data.methods ?? []} transcoding={data.transcoding} />
</div>
<div className="stats-main-grid">
<section className="stats-panel stats-history"><div className="stats-panel-heading"><h2>A look back</h2><span className="stats-unit">Latest 20 plays this month</span></div>{data.recent?.length ? <div className="stats-history-list">{data.recent.map((play) => <article key={play.id}><RecentArtwork key={play.artwork_url ?? play.id} url={play.artwork_url} type={play.type} /><div className="stats-history-title"><strong>{play.series || play.title}</strong><small>{play.series ? `${play.episode} · ${play.title}` : play.type === 'movie' ? 'Movie' : 'Media'}</small><span>{play.client} · {play.method}</span></div><div className="stats-history-time"><strong>{number(play.minutes)} min</strong><time dateTime={play.played_at}>{dateLabel(play.played_at)}</time></div></article>)}</div> : <p className="stats-muted">Plays recorded during this month will appear here.</p>}</section>
<section className="stats-panel stats-requests"><div className="stats-panel-heading"><h2>Your requests</h2><a href="/">View all</a></div><div className="stats-request-total"><strong>{data.requests.total}</strong><span>submitted in {monthLabel(data.month, true)}</span></div><div className="stats-request-counts"><span><strong>{data.requests.pending}</strong> Pending</span><span><strong>{data.requests.approved}</strong> Approved</span><span><strong>{data.requests.declined}</strong> Declined</span></div>{data.requests.recent.length ? <ul className="stats-request-list">{data.requests.recent.map((request) => <li key={request.request_id}><a href={`/requests/${request.request_id}`}>{request.title || `Request ${request.request_id}`}<span aria-hidden="true"></span></a></li>)}</ul> : <p className="stats-muted">No requests recorded during this month.</p>}<p className="stats-muted">Statuses reflect where these requests are now.</p></section>
</div>
<p className="stats-footnote">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.</p>
</>}
</main>
}