diff --git a/src/app/kalender/page.tsx b/src/app/kalender/page.tsx new file mode 100644 index 0000000..f779dbd --- /dev/null +++ b/src/app/kalender/page.tsx @@ -0,0 +1,127 @@ +import Link from "next/link"; +import type { Metadata } from "next"; +import { requireUser } from "@/lib/auth"; +import { getSettings } from "@/lib/settings"; +import { getUpcomingEpisodes } from "@/lib/stats"; +import { AppShell } from "@/components/AppShell"; +import { CalendarMonth } from "@/components/CalendarMonth"; +import { episodeCode, formatDateLong } from "@/lib/labels"; + +export const metadata: Metadata = { title: "Kalender" }; + +/** Monatsangabe aus der URL („2026-09") oder der laufende Monat. */ +function parseMonth(value: string | undefined): Date { + const match = value?.match(/^(\d{4})-(\d{2})$/); + if (match) { + const year = Number(match[1]); + const month = Number(match[2]) - 1; + if (year >= 1900 && year <= 2200 && month >= 0 && month <= 11) { + return new Date(year, month, 1); + } + } + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth(), 1); +} + +function monthParam(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; +} + +export default async function CalendarPage({ + searchParams, +}: { + searchParams: Promise<{ monat?: string }>; +}) { + const user = await requireUser("/kalender"); + const settings = await getSettings(); + const params = await searchParams; + + const month = parseMonth(params.monat); + const from = new Date(month.getFullYear(), month.getMonth(), 1); + // Das Raster reicht in den Vor- und Folgemonat hinein — die Termine auch. + const gridFrom = new Date(from.getTime() - 7 * 86_400_000); + const gridTo = new Date(from.getFullYear(), from.getMonth() + 1, 7); + + const episodes = await getUpcomingEpisodes(user.id, gridFrom, gridTo); + const inMonth = episodes.filter((episode) => episode.airDate.getMonth() === month.getMonth()); + + const previous = new Date(month.getFullYear(), month.getMonth() - 1, 1); + const next = new Date(month.getFullYear(), month.getMonth() + 1, 1); + + return ( + +
+
+

Kalender

+

+ Ausstrahlungstermine der Serien in deiner Bibliothek. +

+
+ + +
+ +
+
+ +
+
+ +
+

+ Termine im {month.toLocaleDateString("de-DE", { month: "long" })} +

+ + {inMonth.length === 0 ? ( +
+ In diesem Monat läuft nichts aus deiner Bibliothek. +
+ ) : ( +
    + {inMonth.map((episode) => ( +
  1. + + {formatDateLong(episode.airDate)} + + + {episode.title} + + {episodeCode(episode.seasonNumber, episode.episodeNumber)} + + {episode.name} + + {episode.runtime && ( + {episode.runtime} min + )} +
  2. + ))} +
+ )} +
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index f94bffb..9d76993 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,82 +2,228 @@ import Link from "next/link"; import { requireUser } from "@/lib/auth"; import { getSettings } from "@/lib/settings"; import { env } from "@/lib/env"; +import { + getContinueWatching, + getNewlyAvailable, + getUpcomingEpisodes, +} from "@/lib/stats"; +import { prisma } from "@/lib/db"; +import { episodeCode, formatDate, formatRelative, isUpcoming } from "@/lib/labels"; import { AppShell } from "@/components/AppShell"; -import { FlimmerMark } from "@/components/brand/FlimmerLogo"; +import { Poster } from "@/components/Poster"; +import { ProgressBar } from "@/components/ProgressBar"; +import { QuickWatch } from "@/components/library/QuickWatch"; /** - * Übersichtsseite. + * Übersicht: der Einstieg für „was schaue ich als Nächstes". * - * In dieser Etappe steht hier die Begrüßung und der Hinweis auf die nächsten - * Schritte; ab Etappe 5 ziehen „Weiterschauen", Kalender und Neuzugänge ein. + * Drei Fragen, drei Bereiche — weiterschauen, was demnächst kommt, was auf der + * Watchlist verfügbar geworden ist. */ export default async function DashboardPage() { const user = await requireUser(); const settings = await getSettings(); + const now = new Date(); + const inFourWeeks = new Date(now.getTime() + 28 * 86_400_000); + + const [continueItems, upcoming, available, libraryCount] = await Promise.all([ + getContinueWatching(user.id, 8), + getUpcomingEpisodes(user.id, now, inFourWeeks), + getNewlyAvailable(user.id, user.region), + prisma.libraryEntry.count({ where: { userId: user.id, deletedAt: null } }), + ]); + + if (libraryCount === 0) { + return ( + + + + ); + } + return ( -
-
-
- -

- Hallo {user.name.split(" ")[0]}. -

-

- Schön, dass du da bist. Sobald du deinen ersten Titel hinzufügst, findest du hier - deine laufenden Serien, die nächsten Episoden und was auf deiner Watchlist gerade - verfügbar geworden ist. -

+

Übersicht

-
- - Titel suchen und hinzufügen - - - Zur Bibliothek +
+
+

+ Weiterschauen +

+ + Alle laufenden + +
+ + {continueItems.length === 0 ? ( +
+ Gerade läuft nichts. Setz eine Serie auf „Schaue gerade", dann steht sie hier. +
+ ) : ( +
    + {continueItems.map((item) => ( +
  • + + + + +
    + + {item.title} + + + {item.next && ( +

    + + {episodeCode(item.next.seasonNumber, item.next.episodeNumber)} + {" "} + {item.next.name} +

    + )} + +

    + {item.watched} von {item.total} Episoden + {item.lastWatchedAt ? ` · zuletzt ${formatRelative(item.lastWatchedAt)}` : ""} +

    + +
    + +
    + +
    + {item.next && isUpcoming(item.next.airDate) ? ( +

    + Nächste Episode erst am {formatDate(item.next.airDate)} +

    + ) : ( + + )} +
    +
    +
  • + ))} +
+ )} +
+ +
+
+
+

+ Demnächst +

+ + Zum Kalender
- {!env.tmdb.isConfigured && ( -

- Es ist noch kein TMDB-Schlüssel hinterlegt. Ohne ihn findet die Suche keine - Metadaten — trage TMDB_API_KEY in der .env ein und starte - die Instanz neu. -

- )} -
-
+
+ {upcoming.length === 0 ? ( +

+ In den nächsten vier Wochen ist keine Episode deiner Serien angekündigt. +

+ ) : ( +
    + {upcoming.slice(0, 6).map((episode) => ( +
  1. + + {formatDate(episode.airDate)} + + + {episode.title} + + {episodeCode(episode.seasonNumber, episode.episodeNumber)} + + +
  2. + ))} +
+ )} +
+ -
- - - +
+

+ Neu verfügbar +

+ +
+ {available.length === 0 ? ( +

+ Auf deiner Watchlist ist zuletzt nichts neu dazugekommen. Sobald ein Titel auf + einem Dienst in {user.region} auftaucht, steht er hier. +

+ ) : ( +
    + {available.slice(0, 5).map((item) => ( +
  • + + + +
    + + {item.title} + +

    + {item.providers.map((provider) => provider.name).join(", ")} ·{" "} + {formatRelative(item.since)} +

    +
    +
  • + ))} +
+ )} +
+
); } -function PlaceholderCard({ title, text }: { title: string; text: string }) { +function EmptyStart({ name, tmdbReady }: { name: string; tmdbReady: boolean }) { return ( -
-

- {title} -

-

{text}

-
+
+
+
+

+ Hallo {name.split(" ")[0]}. +

+

+ Deine Bibliothek ist noch leer. Such dir eine Serie oder einen Film — danach findest du + hier, was als Nächstes dran ist, welche Episoden demnächst kommen und was auf deiner + Watchlist verfügbar geworden ist. +

+ +
+ + Titel suchen + + + Titel selbst anlegen + +
+ + {!tmdbReady && ( +

+ Es ist noch kein TMDB-Schlüssel hinterlegt. Ohne ihn findet die Suche keine Metadaten — + trage TMDB_API_KEY in der .env ein und starte die Instanz neu. +

+ )} +
+
); } diff --git a/src/app/statistik/page.tsx b/src/app/statistik/page.tsx new file mode 100644 index 0000000..761c58b --- /dev/null +++ b/src/app/statistik/page.tsx @@ -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 ( + +
+

Statistik

+
+
+

+ Noch keine Sichtungen. Sobald du die erste Episode abhakst, entstehen hier + Schauzeit, Verteilungen und der Jahresrückblick. +

+ + Zur Bibliothek + +
+
+ ); + } + + const days = totals.minutes / (60 * 24); + + return ( + +
+

Statistik

+

+ Alles gerechnet aus deinen Sichtungen — Rewatches zählen mit. +

+
+ +
+

Gesamte Schauzeit

+

+ {Math.round(totals.minutes / 60).toLocaleString("de-DE")} h +

+

+ {formatMinutes(totals.minutes)} · das sind {days.toFixed(1).replace(".", ",")} Tage am Stück +

+ +
+ + + + +
+
+ +
+

+ Die letzten zwölf Monate +

+

+ Abgehakte Episoden und Filme je Monat. +

+ ({ label, episodes, movies, minutes }))} /> +
+ +
+
+

+ Nach Streamingdienst +

+

+ Wo du tatsächlich geschaut hast — aus dem Feld „wo schaue ich das". +

+ +
+ +
+

+ Nach Genre +

+

+ Titel mit mehreren Genres zählen in jedem mit. +

+ +
+
+ +
+
+

+ {review.year} in Zahlen +

+ {years.length > 1 && ( +
+ {years.slice(0, 6).map((entry) => ( + + {entry} + + ))} +
+ )} +
+ +
+ + + + + +
+ +
+ {review.busiestMonth && ( +

+ Stärkster Monat: + {review.busiestMonth.label} + + {" "} + mit {formatMinutes(review.busiestMonth.minutes)} + +

+ )} + {review.topTitle && ( +

+ Die meiste Zeit ging an: + + {review.topTitle.title} + + ({formatMinutes(review.topTitle.minutes)}) +

+ )} +
+
+ +
+ formatMinutes(item.minutes)} + emptyText="Noch nichts gesehen." + /> + (item.rating ? `${item.rating}/10` : "—")} + emptyText="Noch nichts bewertet." + /> +
+
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +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 ( +
+

+ {heading} +

+ + {items.length === 0 ? ( +

{emptyText}

+ ) : ( +
    + {items.map((item, index) => ( +
  1. + + {index + 1} + + + + + + {item.title} + + + {renderMeta(item)} + +
  2. + ))} +
+ )} +
+ ); +} diff --git a/src/components/CalendarMonth.tsx b/src/components/CalendarMonth.tsx new file mode 100644 index 0000000..3930bd2 --- /dev/null +++ b/src/components/CalendarMonth.tsx @@ -0,0 +1,138 @@ +import Link from "next/link"; +import { clsx } from "clsx"; +import { episodeCode } from "@/lib/labels"; +import type { UpcomingEpisode } from "@/lib/stats"; + +/** + * Monatsraster mit den Ausstrahlungsterminen. + * + * Bewusst als Tabelle ausgezeichnet: ein Kalender *ist* eine Tabelle, und mit + * Screenreader wird daraus dann „Woche 3, Donnerstag, 14." statt einer + * ununterscheidbaren Kachelwüste. + */ + +const WEEKDAYS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; + +function startOfGrid(month: Date): Date { + const first = new Date(month.getFullYear(), month.getMonth(), 1); + // Montag als Wochenanfang: Sonntag (0) wird zu 6. + const weekday = (first.getDay() + 6) % 7; + return new Date(first.getFullYear(), first.getMonth(), 1 - weekday); +} + +function isSameDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + +export function CalendarMonth({ + month, + episodes, +}: { + month: Date; + episodes: UpcomingEpisode[]; +}) { + const today = new Date(); + const start = startOfGrid(month); + + // Sechs Wochen zeigen immer den ganzen Monat, egal auf welchen Tag er fällt. + const days = Array.from({ length: 42 }, (_, index) => { + const date = new Date(start); + date.setDate(start.getDate() + index); + return date; + }); + + const byDay = new Map(); + for (const episode of episodes) { + const key = episode.airDate.toDateString(); + const list = byDay.get(key); + if (list) list.push(episode); + else byDay.set(key, [episode]); + } + + const weeks = Array.from({ length: 6 }, (_, index) => days.slice(index * 7, index * 7 + 7)); + + return ( + + + + + {WEEKDAYS.map((day) => ( + + ))} + + + + {weeks.map((week, weekIndex) => ( + + {week.map((date) => { + const inMonth = date.getMonth() === month.getMonth(); + const isToday = isSameDay(date, today); + const list = byDay.get(date.toDateString()) ?? []; + + return ( + + ); + })} + + ))} + +
+ Ausstrahlungstermine im {month.toLocaleDateString("de-DE", { month: "long", year: "numeric" })} +
+ {day} +
0 + ? { backgroundColor: "color-mix(in oklab, var(--fl-magenta) 7%, transparent)" } + : undefined + } + > + + {date.getDate()} + + +
    + {list.slice(0, 3).map((episode) => ( +
  • + + + {episodeCode(episode.seasonNumber, episode.episodeNumber)} + {" "} + {episode.title} + +
  • + ))} + {list.length > 3 && ( +
  • + + {list.length - 3} weitere +
  • + )} +
+
+ ); +} diff --git a/src/components/library/QuickWatch.tsx b/src/components/library/QuickWatch.tsx new file mode 100644 index 0000000..454c062 --- /dev/null +++ b/src/components/library/QuickWatch.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { undoAction, watchNextAction } from "@/app/actions/library"; +import { useToasts } from "@/components/Toasts"; + +/** + * „+1 Episode" für die Übersicht — derselbe Griff wie auf der Detailseite, + * nur ohne Kontext drumherum. + */ +export function QuickWatch({ entryId }: { entryId: string }) { + const router = useRouter(); + const { report } = useToasts(); + const [pending, startTransition] = useTransition(); + + async function undo(watchIds: string[]) { + await undoAction(entryId, watchIds); + router.refresh(); + } + + return ( + + ); +}