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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user