feat: Seiten für Übersicht, Kalender und Statistik
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.
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user