Files
Magent/frontend/app/insights/reports/page.tsx
T
Assclaw fd6671cf7e
Magent CI/CD / verify (push) Successful in 5m20s
Magent CI/CD / deploy-beta (push) Successful in 1m41s
feat: add backup recovery, setup wizard and user-view guards
2026-09-18 17:23:03 +12:00

534 lines
21 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 { useEffectiveRole } from "../../lib/viewMode";
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 isAdmin = useEffectiveRole(data?.is_admin ? "admin" : "user") === "admin";
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(() => {
void revision;
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>
{isAdmin
? "Connect Jellystat to bring your monthly viewing reports into Magent."
: "Monthly reports will appear once your administrator connects Jellystat."}
</p>
{isAdmin && (
<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>
{isAdmin && (
<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>
{data.patterns && (
<>
<section className="report-pattern-summary" aria-label="Viewing insights">
<article>
<span>Average play</span>
<strong>
{decimal(data.patterns.average_play_minutes)} <small>min</small>
</strong>
<p>Time per recorded playback session.</p>
</article>
<article>
<span>Longest play</span>
<strong>
{decimal(data.patterns.longest_play_minutes)} <small>min</small>
</strong>
<p>Your longest recorded session this month.</p>
</article>
<article>
<span>Weekend viewing</span>
<strong>
{decimal(data.patterns.weekend_percent)}
<small>%</small>
</strong>
<p>Share of viewing on Saturday and Sunday (UTC).</p>
</article>
</section>
<div className="stats-main-grid">
<BreakdownCard title="Your week in viewing (UTC)" rows={data.patterns.weekdays} />
<BreakdownCard title="Movies, TV and more" rows={data.patterns.media} />
</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}`}>
<RecentArtwork url={title.artwork_url} type={title.type} />
<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>
);
}