diff --git a/src/lib/stats.ts b/src/lib/stats.ts new file mode 100644 index 0000000..561dba5 --- /dev/null +++ b/src/lib/stats.ts @@ -0,0 +1,557 @@ +import "server-only"; +import { prisma } from "./db"; +import { SPECIALS_SEASON } from "./catalog"; + +/** + * Auswertungen für Übersicht, Kalender und Statistik. + * + * Alles wird aus den Sichtungen (`Watch`) berechnet — es gibt keine + * fortgeschriebenen Zähler, die auseinanderlaufen könnten. Wo es auf Tempo + * ankommt, laufen die Abfragen als SQL, weil Prisma für Zeitreihen keine + * Gruppierung nach Monat kennt. + */ + +// --------------------------------------------------------------------------- +// Übersicht +// --------------------------------------------------------------------------- + +export type ContinueItem = { + entryId: string; + titleId: string; + title: string; + posterPath: string | null; + watched: number; + total: number; + percent: number; + lastWatchedAt: Date | null; + next: { + id: string; + name: string; + seasonNumber: number; + episodeNumber: number; + airDate: Date | null; + runtime: number | null; + } | null; +}; + +/** + * „Weiterschauen": laufende Serien, zuletzt angefasste zuerst. + * Serien ohne offene Episode fallen heraus — die sind durch. + */ +export async function getContinueWatching(userId: string, limit = 12): Promise { + const entries = await prisma.libraryEntry.findMany({ + where: { + userId, + deletedAt: null, + status: { in: ["WATCHING", "PAUSED"] }, + title: { type: "SERIES" }, + }, + orderBy: { updatedAt: "desc" }, + take: limit * 2, + select: { + id: true, + titleId: true, + currentRun: true, + updatedAt: true, + title: { select: { title: true, posterPath: true, episodeCount: true } }, + }, + }); + + if (entries.length === 0) return []; + + const items: ContinueItem[] = []; + + for (const entry of entries) { + const [watched, next, lastWatch] = await Promise.all([ + prisma.watch.count({ + where: { + userId, + entryId: entry.id, + run: entry.currentRun, + episode: { seasonNumber: { gt: SPECIALS_SEASON } }, + }, + }), + prisma.episode.findFirst({ + where: { + titleId: entry.titleId, + seasonNumber: { gt: SPECIALS_SEASON }, + watches: { none: { userId, run: entry.currentRun } }, + }, + orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }], + select: { + id: true, + name: true, + seasonNumber: true, + episodeNumber: true, + airDate: true, + runtime: true, + }, + }), + prisma.watch.findFirst({ + where: { userId, entryId: entry.id, run: entry.currentRun }, + orderBy: { watchedAt: "desc" }, + select: { watchedAt: true }, + }), + ]); + + if (!next) continue; + + const total = entry.title.episodeCount; + items.push({ + entryId: entry.id, + titleId: entry.titleId, + title: entry.title.title, + posterPath: entry.title.posterPath, + watched, + total, + percent: total > 0 ? Math.round((watched / total) * 100) : 0, + lastWatchedAt: lastWatch?.watchedAt ?? null, + next, + }); + + if (items.length >= limit) break; + } + + return items; +} + +export type UpcomingEpisode = { + episodeId: string; + titleId: string; + title: string; + posterPath: string | null; + name: string; + seasonNumber: number; + episodeNumber: number; + airDate: Date; + runtime: number | null; +}; + +/** + * Kommende Episoden getrackter Serien. + * + * „Getrackt" heißt: in der Bibliothek und nicht abgebrochen. Abgeschlossene + * Serien bleiben bewusst drin — wer bei einer laufenden Serie auf dem Stand + * ist, gilt als „abgeschlossen" und will die nächste Folge erst recht im + * Kalender sehen. Nur Abgebrochenes fällt heraus. + */ +export async function getUpcomingEpisodes( + userId: string, + from: Date, + to: Date, +): Promise { + const episodes = await prisma.episode.findMany({ + where: { + airDate: { gte: from, lte: to }, + seasonNumber: { gt: SPECIALS_SEASON }, + title: { + entries: { + some: { userId, deletedAt: null, status: { not: "DROPPED" } }, + }, + }, + }, + orderBy: [{ airDate: "asc" }, { seasonNumber: "asc" }, { episodeNumber: "asc" }], + select: { + id: true, + name: true, + seasonNumber: true, + episodeNumber: true, + airDate: true, + runtime: true, + title: { select: { id: true, title: true, posterPath: true } }, + }, + }); + + return episodes.flatMap((episode) => + episode.airDate + ? [ + { + episodeId: episode.id, + titleId: episode.title.id, + title: episode.title.title, + posterPath: episode.title.posterPath, + name: episode.name, + seasonNumber: episode.seasonNumber, + episodeNumber: episode.episodeNumber, + airDate: episode.airDate, + runtime: episode.runtime, + }, + ] + : [], + ); +} + +export type AvailableItem = { + titleId: string; + title: string; + posterPath: string | null; + providers: { name: string; color: string | null }[]; + since: Date; +}; + +/** + * Watchlist-Titel, die seit Kurzem auf einem Dienst verfügbar sind. + * Grundlage ist `firstSeenAt` der Verfügbarkeit — das steht seit dem ersten + * Abgleich fest und wandert nicht bei jedem Sync weiter. + */ +export async function getNewlyAvailable( + userId: string, + region: string, + sinceDays = 30, +): Promise { + const since = new Date(Date.now() - sinceDays * 86_400_000); + + const rows = await prisma.titleProvider.findMany({ + where: { + region, + kind: { in: ["FLATRATE", "FREE", "ADS"] }, + firstSeenAt: { gte: since }, + title: { entries: { some: { userId, deletedAt: null, status: "WATCHLIST" } } }, + }, + orderBy: { firstSeenAt: "desc" }, + select: { + firstSeenAt: true, + provider: { select: { name: true, color: true } }, + title: { select: { id: true, title: true, posterPath: true } }, + }, + }); + + const byTitle = new Map(); + for (const row of rows) { + const existing = byTitle.get(row.title.id); + if (existing) { + if (!existing.providers.some((provider) => provider.name === row.provider.name)) { + existing.providers.push(row.provider); + } + continue; + } + byTitle.set(row.title.id, { + titleId: row.title.id, + title: row.title.title, + posterPath: row.title.posterPath, + providers: [row.provider], + since: row.firstSeenAt, + }); + } + + return [...byTitle.values()]; +} + +// --------------------------------------------------------------------------- +// Statistik +// --------------------------------------------------------------------------- + +export type Totals = { + minutes: number; + episodes: number; + movies: number; + titlesCompleted: number; + seriesCount: number; + movieCount: number; +}; + +export async function getTotals(userId: string): Promise { + const [minutes, episodes, movies, completed, byType] = await Promise.all([ + prisma.watch.aggregate({ where: { userId }, _sum: { minutes: true } }), + prisma.watch.count({ where: { userId, episodeId: { not: null } } }), + prisma.watch.count({ where: { userId, episodeId: null } }), + prisma.libraryEntry.count({ where: { userId, deletedAt: null, status: "COMPLETED" } }), + prisma.libraryEntry.groupBy({ + by: ["titleId"], + where: { userId, deletedAt: null }, + _count: { _all: true }, + }), + ]); + + const typeCounts = await prisma.title.groupBy({ + by: ["type"], + where: { id: { in: byType.map((row) => row.titleId) } }, + _count: { _all: true }, + }); + + return { + minutes: minutes._sum.minutes ?? 0, + episodes, + movies, + titlesCompleted: completed, + seriesCount: typeCounts.find((row) => row.type === "SERIES")?._count._all ?? 0, + movieCount: typeCounts.find((row) => row.type === "MOVIE")?._count._all ?? 0, + }; +} + +export type MonthlyPoint = { + /** Monatsanfang */ + month: Date; + label: string; + episodes: number; + movies: number; + minutes: number; +}; + +/** Aktivität je Monat, lückenlos — auch Monate ohne Sichtung erscheinen. */ +export async function getMonthlyActivity(userId: string, months = 12): Promise { + const start = new Date(); + start.setDate(1); + start.setHours(0, 0, 0, 0); + start.setMonth(start.getMonth() - (months - 1)); + + // Der Monatsschlüssel kommt als Text aus der Datenbank („2026-04"). + // Ein Zeitstempel würde hier schiefgehen: date_trunc liefert Mitternacht in + // der Sitzungszeitzone, JS legt beim Vergleich die lokale Zone an — zwei + // Stunden Versatz genügen, damit kein einziger Schlüssel mehr passt. + const rows = await prisma.$queryRaw< + { month: string; episodes: bigint; movies: bigint; minutes: bigint | null }[] + >` + SELECT + to_char(date_trunc('month', "watchedAt"), 'YYYY-MM') AS month, + count(*) FILTER (WHERE "episodeId" IS NOT NULL) AS episodes, + count(*) FILTER (WHERE "episodeId" IS NULL) AS movies, + sum("minutes") AS minutes + FROM "Watch" + WHERE "userId" = ${userId} AND "watchedAt" >= ${start} + GROUP BY 1 + ORDER BY 1 + `; + + const byMonth = new Map(rows.map((row) => [row.month, row])); + const points: MonthlyPoint[] = []; + + for (let index = 0; index < months; index += 1) { + const month = new Date(start); + month.setMonth(start.getMonth() + index); + const key = `${month.getFullYear()}-${String(month.getMonth() + 1).padStart(2, "0")}`; + const row = byMonth.get(key); + + points.push({ + month, + label: month.toLocaleDateString("de-DE", { month: "short" }), + episodes: Number(row?.episodes ?? 0), + movies: Number(row?.movies ?? 0), + minutes: Number(row?.minutes ?? 0), + }); + } + + return points; +} + +export type Slice = { label: string; minutes: number; count: number; color: string | null }; + +/** + * Verteilung der Schauzeit nach Streamingdienst. + * Grundlage ist das Feld „wo schaue ich das" — es sagt, wo tatsächlich + * geschaut wurde, nicht bloß, wo es verfügbar wäre. + */ +export async function getProviderDistribution(userId: string): Promise { + const rows = await prisma.$queryRaw<{ label: string; minutes: bigint; count: bigint }[]>` + SELECT + coalesce(nullif(e."whereToWatch", ''), 'Ohne Angabe') AS label, + sum(w."minutes") AS minutes, + count(*) AS count + FROM "Watch" w + JOIN "LibraryEntry" e ON e."id" = w."entryId" + WHERE w."userId" = ${userId} + GROUP BY 1 + ORDER BY minutes DESC + `; + + const providers = await prisma.watchProvider.findMany({ select: { name: true, color: true } }); + const colorByName = new Map(providers.map((provider) => [provider.name, provider.color])); + + return rows.map((row) => ({ + label: row.label, + minutes: Number(row.minutes ?? 0), + count: Number(row.count), + color: colorByName.get(row.label) ?? null, + })); +} + +/** Verteilung der Schauzeit nach Genre. Mehrfachnennungen zählen mehrfach. */ +export async function getGenreDistribution(userId: string): Promise { + const rows = await prisma.$queryRaw<{ label: string; minutes: bigint; count: bigint }[]>` + SELECT + genre AS label, + sum(w."minutes") AS minutes, + count(*) AS count + FROM "Watch" w + JOIN "Title" t ON t."id" = w."titleId" + CROSS JOIN LATERAL unnest(t."genres") AS genre + WHERE w."userId" = ${userId} + GROUP BY 1 + ORDER BY minutes DESC + LIMIT 12 + `; + + return rows.map((row) => ({ + label: row.label, + minutes: Number(row.minutes ?? 0), + count: Number(row.count), + color: null, + })); +} + +export type TopTitle = { + titleId: string; + title: string; + posterPath: string | null; + rating: number | null; + minutes: number; + type: "SERIES" | "MOVIE"; +}; + +/** Bestbewertete Titel. */ +export async function getTopRated(userId: string, limit = 6): Promise { + const entries = await prisma.libraryEntry.findMany({ + where: { userId, deletedAt: null, rating: { not: null } }, + orderBy: [{ rating: "desc" }, { updatedAt: "desc" }], + take: limit, + select: { + rating: true, + title: { select: { id: true, title: true, posterPath: true, type: true } }, + }, + }); + + return entries.map((entry) => ({ + titleId: entry.title.id, + title: entry.title.title, + posterPath: entry.title.posterPath, + rating: entry.rating, + minutes: 0, + type: entry.title.type, + })); +} + +/** Titel mit der meisten investierten Zeit. */ +export async function getMostWatched(userId: string, limit = 6): Promise { + const rows = await prisma.watch.groupBy({ + by: ["titleId"], + where: { userId }, + _sum: { minutes: true }, + orderBy: { _sum: { minutes: "desc" } }, + take: limit, + }); + + if (rows.length === 0) return []; + + const titles = await prisma.title.findMany({ + where: { id: { in: rows.map((row) => row.titleId) } }, + select: { id: true, title: true, posterPath: true, type: true }, + }); + const byId = new Map(titles.map((title) => [title.id, title])); + + return rows.flatMap((row) => { + const title = byId.get(row.titleId); + return title + ? [ + { + titleId: title.id, + title: title.title, + posterPath: title.posterPath, + rating: null, + minutes: row._sum.minutes ?? 0, + type: title.type, + }, + ] + : []; + }); +} + +export type YearReview = { + year: number; + minutes: number; + episodes: number; + movies: number; + titlesStarted: number; + titlesCompleted: number; + busiestMonth: { label: string; minutes: number } | null; + topTitle: TopTitle | null; +}; + +/** „Das Jahr in Zahlen". */ +export async function getYearReview(userId: string, year: number): Promise { + const from = new Date(Date.UTC(year, 0, 1)); + const to = new Date(Date.UTC(year + 1, 0, 1)); + + const [aggregate, episodes, movies, started, completed, months, top] = await Promise.all([ + prisma.watch.aggregate({ + where: { userId, watchedAt: { gte: from, lt: to } }, + _sum: { minutes: true }, + }), + prisma.watch.count({ + where: { userId, watchedAt: { gte: from, lt: to }, episodeId: { not: null } }, + }), + prisma.watch.count({ where: { userId, watchedAt: { gte: from, lt: to }, episodeId: null } }), + prisma.libraryEntry.count({ where: { userId, startedAt: { gte: from, lt: to } } }), + prisma.libraryEntry.count({ where: { userId, finishedAt: { gte: from, lt: to } } }), + prisma.$queryRaw<{ month: string; minutes: bigint }[]>` + SELECT to_char(date_trunc('month', "watchedAt"), 'YYYY-MM') AS month, sum("minutes") AS minutes + FROM "Watch" + WHERE "userId" = ${userId} AND "watchedAt" >= ${from} AND "watchedAt" < ${to} + GROUP BY 1 + ORDER BY minutes DESC + LIMIT 1 + `, + prisma.watch.groupBy({ + by: ["titleId"], + where: { userId, watchedAt: { gte: from, lt: to } }, + _sum: { minutes: true }, + orderBy: { _sum: { minutes: "desc" } }, + take: 1, + }), + ]); + + const busiest = months[0]; + const topTitleRow = top[0]; + const topTitle = topTitleRow + ? await prisma.title.findUnique({ + where: { id: topTitleRow.titleId }, + select: { id: true, title: true, posterPath: true, type: true }, + }) + : null; + + return { + year, + minutes: aggregate._sum.minutes ?? 0, + episodes, + movies, + titlesStarted: started, + titlesCompleted: completed, + busiestMonth: busiest + ? { + label: monthLabel(busiest.month), + minutes: Number(busiest.minutes ?? 0), + } + : null, + topTitle: + topTitle && topTitleRow + ? { + titleId: topTitle.id, + title: topTitle.title, + posterPath: topTitle.posterPath, + rating: null, + minutes: topTitleRow._sum.minutes ?? 0, + type: topTitle.type, + } + : null, + }; +} + +/** „2026-04" → „April 2026" */ +function monthLabel(key: string): string { + const [year, month] = key.split("-").map(Number); + if (!year || !month) return key; + return new Date(year, month - 1, 1).toLocaleDateString("de-DE", { + month: "long", + year: "numeric", + }); +} + +/** Jahre, in denen überhaupt etwas gesehen wurde — für die Jahresauswahl. */ +export async function getActiveYears(userId: string): Promise { + const rows = await prisma.$queryRaw<{ year: number }[]>` + SELECT DISTINCT extract(year FROM "watchedAt")::int AS year + FROM "Watch" + WHERE "userId" = ${userId} + ORDER BY year DESC + `; + return rows.map((row) => row.year); +}