feat: Auswertungen für Übersicht, Kalender und Statistik

Alles wird aus den Sichtungen berechnet — es gibt keine fortgeschriebenen
Zähler, die auseinanderlaufen könnten. Zeitreihen laufen als SQL, weil Prisma
keine Gruppierung nach Monat kennt.

Der Monatsschlüssel kommt bewusst als Text aus der Datenbank: date_trunc
liefert Mitternacht in der Sitzungszeitzone, JavaScript legt beim Vergleich
die lokale Zone an — zwei Stunden Versatz genügen, damit kein Schlüssel mehr
passt und das Diagramm leer bleibt.
This commit is contained in:
Florian Wilde
2026-09-01 19:54:15 +02:00
parent ed6011384a
commit b53ee828ba
+557
View File
@@ -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<ContinueItem[]> {
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<UpcomingEpisode[]> {
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<AvailableItem[]> {
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<string, AvailableItem>();
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<Totals> {
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<MonthlyPoint[]> {
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<Slice[]> {
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<Slice[]> {
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<TopTitle[]> {
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<TopTitle[]> {
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<YearReview> {
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<number[]> {
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);
}