Der Kalender ist als Tabelle ausgezeichnet — ein Kalender ist eine Tabelle, und mit Screenreader wird daraus dann „Woche 3, Donnerstag, 24." statt einer ununterscheidbaren Kachelwüste.
268 lines
10 KiB
TypeScript
268 lines
10 KiB
TypeScript
import Link from "next/link";
|
|
import type { Metadata } from "next";
|
|
import { requireUser } from "@/lib/auth";
|
|
import { getSettings } from "@/lib/settings";
|
|
import {
|
|
getActiveYears,
|
|
getGenreDistribution,
|
|
getMonthlyActivity,
|
|
getMostWatched,
|
|
getProviderDistribution,
|
|
getTopRated,
|
|
getTotals,
|
|
getYearReview,
|
|
} from "@/lib/stats";
|
|
import { formatMinutes } from "@/lib/labels";
|
|
import { AppShell } from "@/components/AppShell";
|
|
import { Poster } from "@/components/Poster";
|
|
import { DistributionBars, MonthlyChart } from "@/components/stats/Charts";
|
|
|
|
export const metadata: Metadata = { title: "Statistik" };
|
|
|
|
export default async function StatsPage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<{ jahr?: string }>;
|
|
}) {
|
|
const user = await requireUser("/statistik");
|
|
const settings = await getSettings();
|
|
const params = await searchParams;
|
|
|
|
const years = await getActiveYears(user.id);
|
|
const requested = Number.parseInt(params.jahr ?? "", 10);
|
|
const year = years.includes(requested) ? requested : (years[0] ?? new Date().getFullYear());
|
|
|
|
const [totals, monthly, providers, genres, topRated, mostWatched, review] = await Promise.all([
|
|
getTotals(user.id),
|
|
getMonthlyActivity(user.id, 12),
|
|
getProviderDistribution(user.id),
|
|
getGenreDistribution(user.id),
|
|
getTopRated(user.id, 5),
|
|
getMostWatched(user.id, 5),
|
|
getYearReview(user.id, year),
|
|
]);
|
|
|
|
if (totals.minutes === 0 && totals.episodes === 0 && totals.movies === 0) {
|
|
return (
|
|
<AppShell user={user} instanceName={settings.instanceName}>
|
|
<header className="mb-5">
|
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Statistik</h1>
|
|
</header>
|
|
<div className="fl-card p-8 text-center">
|
|
<p className="mx-auto max-w-md text-[var(--fl-muted)]">
|
|
Noch keine Sichtungen. Sobald du die erste Episode abhakst, entstehen hier
|
|
Schauzeit, Verteilungen und der Jahresrückblick.
|
|
</p>
|
|
<Link href="/bibliothek" className="fl-btn fl-btn-primary mt-4">
|
|
Zur Bibliothek
|
|
</Link>
|
|
</div>
|
|
</AppShell>
|
|
);
|
|
}
|
|
|
|
const days = totals.minutes / (60 * 24);
|
|
|
|
return (
|
|
<AppShell user={user} instanceName={settings.instanceName}>
|
|
<header className="mb-6">
|
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Statistik</h1>
|
|
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
|
Alles gerechnet aus deinen Sichtungen — Rewatches zählen mit.
|
|
</p>
|
|
</header>
|
|
|
|
<section className="fl-card mb-6 p-5 sm:p-7" aria-label="Gesamtschauzeit">
|
|
<p className="text-sm font-medium text-[var(--fl-muted)]">Gesamte Schauzeit</p>
|
|
<p className="fl-gradient-text mt-1 text-5xl font-semibold tabular-nums sm:text-6xl">
|
|
{Math.round(totals.minutes / 60).toLocaleString("de-DE")} h
|
|
</p>
|
|
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
|
{formatMinutes(totals.minutes)} · das sind {days.toFixed(1).replace(".", ",")} Tage am Stück
|
|
</p>
|
|
|
|
<dl className="mt-5 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
|
<Stat label="Episoden" value={totals.episodes.toLocaleString("de-DE")} />
|
|
<Stat label="Filme" value={totals.movies.toLocaleString("de-DE")} />
|
|
<Stat label="Abgeschlossen" value={totals.titlesCompleted.toLocaleString("de-DE")} />
|
|
<Stat
|
|
label="In der Bibliothek"
|
|
value={`${totals.seriesCount} Serien · ${totals.movieCount} Filme`}
|
|
/>
|
|
</dl>
|
|
</section>
|
|
|
|
<section className="fl-card mb-6 p-5" aria-labelledby="verlauf">
|
|
<h2 id="verlauf" className="mb-1 text-lg font-semibold">
|
|
Die letzten zwölf Monate
|
|
</h2>
|
|
<p className="mb-4 text-sm text-[var(--fl-muted)]">
|
|
Abgehakte Episoden und Filme je Monat.
|
|
</p>
|
|
<MonthlyChart points={monthly.map(({ label, episodes, movies, minutes }) => ({ label, episodes, movies, minutes }))} />
|
|
</section>
|
|
|
|
<div className="mb-6 grid gap-6 lg:grid-cols-2">
|
|
<section className="fl-card p-5" aria-labelledby="dienste">
|
|
<h2 id="dienste" className="mb-1 text-lg font-semibold">
|
|
Nach Streamingdienst
|
|
</h2>
|
|
<p className="mb-4 text-sm text-[var(--fl-muted)]">
|
|
Wo du tatsächlich geschaut hast — aus dem Feld „wo schaue ich das".
|
|
</p>
|
|
<DistributionBars
|
|
rows={providers.slice(0, 8)}
|
|
emptyText="Noch keine Sichtung einem Dienst zugeordnet."
|
|
useBrandColors
|
|
/>
|
|
</section>
|
|
|
|
<section className="fl-card p-5" aria-labelledby="genres">
|
|
<h2 id="genres" className="mb-1 text-lg font-semibold">
|
|
Nach Genre
|
|
</h2>
|
|
<p className="mb-4 text-sm text-[var(--fl-muted)]">
|
|
Titel mit mehreren Genres zählen in jedem mit.
|
|
</p>
|
|
<DistributionBars rows={genres.slice(0, 8)} emptyText="Noch keine Genres erfasst." />
|
|
</section>
|
|
</div>
|
|
|
|
<section className="fl-card mb-6 p-5" aria-labelledby="rueckblick">
|
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
|
<h2 id="rueckblick" className="text-lg font-semibold">
|
|
{review.year} in Zahlen
|
|
</h2>
|
|
{years.length > 1 && (
|
|
<div className="flex flex-wrap gap-1.5" role="group" aria-label="Jahr wählen">
|
|
{years.slice(0, 6).map((entry) => (
|
|
<Link
|
|
key={entry}
|
|
href={`/statistik?jahr=${entry}`}
|
|
aria-current={entry === review.year ? "true" : undefined}
|
|
className="rounded-full border px-3 py-1 text-sm font-medium transition-colors"
|
|
style={
|
|
entry === review.year
|
|
? {
|
|
backgroundImage: "var(--fl-gradient)",
|
|
color: "var(--fl-on-accent)",
|
|
borderColor: "transparent",
|
|
}
|
|
: { borderColor: "var(--fl-border)", color: "var(--fl-muted)" }
|
|
}
|
|
>
|
|
{entry}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
|
|
<Stat label="Schauzeit" value={formatMinutes(review.minutes)} />
|
|
<Stat label="Episoden" value={review.episodes.toLocaleString("de-DE")} />
|
|
<Stat label="Filme" value={review.movies.toLocaleString("de-DE")} />
|
|
<Stat label="Neu angefangen" value={review.titlesStarted.toLocaleString("de-DE")} />
|
|
<Stat label="Abgeschlossen" value={review.titlesCompleted.toLocaleString("de-DE")} />
|
|
</dl>
|
|
|
|
<div className="mt-5 grid gap-4 sm:grid-cols-2">
|
|
{review.busiestMonth && (
|
|
<p className="rounded-xl border border-[var(--fl-border)] p-3 text-sm">
|
|
<span className="text-[var(--fl-faint)]">Stärkster Monat: </span>
|
|
<span className="font-medium">{review.busiestMonth.label}</span>
|
|
<span className="text-[var(--fl-muted)]">
|
|
{" "}
|
|
mit {formatMinutes(review.busiestMonth.minutes)}
|
|
</span>
|
|
</p>
|
|
)}
|
|
{review.topTitle && (
|
|
<p className="rounded-xl border border-[var(--fl-border)] p-3 text-sm">
|
|
<span className="text-[var(--fl-faint)]">Die meiste Zeit ging an: </span>
|
|
<Link href={`/titel/${review.topTitle.titleId}`} className="font-medium hover:text-[var(--fl-accent)]">
|
|
{review.topTitle.title}
|
|
</Link>
|
|
<span className="text-[var(--fl-muted)]"> ({formatMinutes(review.topTitle.minutes)})</span>
|
|
</p>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
<div className="grid gap-6 lg:grid-cols-2">
|
|
<TitleList
|
|
id="meistzeit"
|
|
heading="Wo die meiste Zeit hinging"
|
|
items={mostWatched}
|
|
renderMeta={(item) => formatMinutes(item.minutes)}
|
|
emptyText="Noch nichts gesehen."
|
|
/>
|
|
<TitleList
|
|
id="bestbewertet"
|
|
heading="Deine besten Bewertungen"
|
|
items={topRated}
|
|
renderMeta={(item) => (item.rating ? `${item.rating}/10` : "—")}
|
|
emptyText="Noch nichts bewertet."
|
|
/>
|
|
</div>
|
|
</AppShell>
|
|
);
|
|
}
|
|
|
|
function Stat({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div>
|
|
<dt className="text-xs uppercase tracking-wide text-[var(--fl-faint)]">{label}</dt>
|
|
<dd className="mt-0.5 text-lg font-semibold tabular-nums">{value}</dd>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TitleList({
|
|
id,
|
|
heading,
|
|
items,
|
|
renderMeta,
|
|
emptyText,
|
|
}: {
|
|
id: string;
|
|
heading: string;
|
|
items: { titleId: string; title: string; posterPath: string | null; rating: number | null; minutes: number; type: string }[];
|
|
renderMeta: (item: { rating: number | null; minutes: number }) => string;
|
|
emptyText: string;
|
|
}) {
|
|
return (
|
|
<section className="fl-card p-5" aria-labelledby={id}>
|
|
<h2 id={id} className="mb-4 text-lg font-semibold">
|
|
{heading}
|
|
</h2>
|
|
|
|
{items.length === 0 ? (
|
|
<p className="text-sm text-[var(--fl-faint)]">{emptyText}</p>
|
|
) : (
|
|
<ol className="space-y-3">
|
|
{items.map((item, index) => (
|
|
<li key={item.titleId} className="flex items-center gap-3">
|
|
<span className="w-4 shrink-0 text-right font-mono text-sm text-[var(--fl-faint)]">
|
|
{index + 1}
|
|
</span>
|
|
<Link href={`/titel/${item.titleId}`} className="w-10 shrink-0">
|
|
<Poster path={item.posterPath} alt={item.title} size="w154" />
|
|
</Link>
|
|
<Link
|
|
href={`/titel/${item.titleId}`}
|
|
className="min-w-0 flex-1 truncate text-sm font-medium hover:text-[var(--fl-accent)]"
|
|
>
|
|
{item.title}
|
|
</Link>
|
|
<span className="shrink-0 text-sm tabular-nums text-[var(--fl-muted)]">
|
|
{renderMeta(item)}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|