10 Commits
Author SHA1 Message Date
Florian Wilde 7ac598ea81 docs: Changelog, Release-Notes und DEVLOG für 0.5.0 2026-09-01 19:54:17 +02:00
Florian Wilde 075b42584a fix: Kalender zeigt auch abgeschlossene, weiterlaufende Serien
Wer bei einer laufenden Serie auf dem Stand ist, gilt als „abgeschlossen" —
und verlor damit die kommenden Folgen aus dem Kalender, obwohl man genau die
dort sehen will. Jetzt faellt nur Abgebrochenes heraus.

Ausserdem: Der SVG-Titel des Diagramms brach die Hydration, weil React
<title> als Dokument-Metadatum behandelt und aus dem SVG heraushebt.
2026-09-01 19:54:16 +02:00
Florian Wilde 8c872b240a 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.
2026-09-01 19:54:16 +02:00
Florian Wilde 0e51df7bad feat: Diagramme als handgeschriebenes SVG
Ohne Chart-Bibliothek: die Diagramme sind schlicht, sollen sich an die
Design-Tokens halten und in beiden Farbschemata funktionieren.

Die Serienfarben sind abgedunkelte Signaturfarben. Sie bestehen in beiden
Modi die Pruefungen auf Helligkeitsband, Saettigung, Farbfehlsichtigkeit
(schlechtestes Paar 12,1 unter Deuteranopie) und Kontrast; die vollen
Signaturtoene waeren auf dunklem Grund zu hell.

Markenfarben der Dienste kommen nur zum Zug, wenn sie auf hellem und dunklem
Grund mindestens 3:1 erreichen — sonst faellt der Balken je nach Farbschema
in den Hintergrund.
2026-09-01 19:54:15 +02:00
Florian Wilde b53ee828ba 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.
2026-09-01 19:54:15 +02:00
Florian Wilde ed6011384a feat: Beispieluniversum im Seed und Notizen fuer 0.4.0 2026-09-01 19:39:35 +02:00
Florian Wilde 5cf4fc48a6 fix: server-only-Kette aus dem Browser-Bundle lösen
Die Posterkachel zog über lib/images.ts die serverseitige Cache-Logik in den
Browser-Bundle, sobald sie aus einer Client-Komponente heraus gerendert wurde
— der Build brach ab, sobald eine solche Seite dazukam.

Die reine URL-Bildung liegt jetzt in lib/image-url.ts und darf überall
verwendet werden; Abruf und Dateicache bleiben hinter server-only.
2026-09-01 19:39:34 +02:00
Florian Wilde 5ee6946fd5 feat: Oberfläche für Universen
Umsortieren geht per Ziehen und über Pfeilknöpfe: reines Drag & Drop wäre mit
Tastatur nicht bedienbar und auf dem Handy fummelig — die Knöpfe sind der
verlässliche Weg, das Ziehen der bequeme.

Die Erscheinungsreihenfolge lässt sich gar nicht erst anfassen, weil sie sich
aus den Daten ergibt.
2026-09-01 19:39:32 +02:00
Florian Wilde 90497ddc3b test: Universen absichern
12 Tests für Reihenfolgen, Fortschritt über Filme und Serien hinweg,
Freigaben mit Lese- und Schreibrecht sowie die Rechteprüfung gegenüber
Fremden.
2026-09-01 19:39:32 +02:00
Florian Wilde 190d7b3796 feat: Universen mit mehreren Reihenfolgen und Fortschritt
Zwei Dinge unterscheiden ein Universum von einer Liste: es hat mehrere
Reihenfolgen, und der Fortschritt ist der des jeweiligen Betrachters.

Die Erscheinungsreihenfolge wird aus den Erscheinungsdaten berechnet und nie
gespeichert — sie stimmt damit immer, auch wenn später Titel dazukommen.
Alles andere ist frei sortierbar.

Ein geteiltes Universum zeigt jeder Person ihren eigenen Stand; bei Serien
schrumpft die Restlaufzeit anteilig mit den gesehenen Episoden.
2026-09-01 19:39:31 +02:00
27 changed files with 4282 additions and 90 deletions
+85 -1
View File
@@ -7,6 +7,88 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht] ## [Unveröffentlicht]
## [0.5.0] — 2026-09-01
Übersicht, Kalender und Statistik.
### Hinzugefügt
- **Übersicht** mit „Weiterschauen" (laufende Serien samt nächster Episode,
Fortschritt und „+1 Episode" direkt aus der Kachel), den Ausstrahlungen der
nächsten vier Wochen und den Watchlist-Titeln, die zuletzt auf einem Dienst
verfügbar geworden sind.
- **Kalender** als Monatsraster mit Blättern und Sprung zum heutigen Tag,
darunter die Termine des Monats als Liste. Ausgezeichnet als Tabelle — ein
Kalender *ist* eine Tabelle, und mit Screenreader wird daraus dann
„Woche 3, Donnerstag, 24." statt einer ununterscheidbaren Kachelwüste.
- **Statistikseite** mit Gesamtschauzeit, Episoden und Filmen je Monat als
Diagramm, Verteilung nach Streamingdienst und Genre, Jahresrückblick
(„2026 in Zahlen") sowie den Listen „wo die meiste Zeit hinging" und
„deine besten Bewertungen". Alles aus den Sichtungen gerechnet, Rewatches
zählen mit.
- **Diagramme als handgeschriebenes SVG** — ohne Chart-Bibliothek, dafür an
den Design-Tokens. Die beiden Serienfarben sind abgedunkelte Varianten der
Signaturfarben; sie bestehen in **beiden** Farbschemata die Prüfungen auf
Helligkeitsband, Sättigung, Farbfehlsichtigkeit und Kontrast. Die vollen
Signaturtöne wären auf dunklem Grund zu hell. Legende, Achsenwerte,
Tooltip beim Überfahren und eine für Screenreader hinterlegte Tabelle
sorgen dafür, dass die Aussage nie allein an der Farbe hängt.
- Markenfarben der Streamingdienste in der Verteilung, aber nur, wenn sie auf
hellem *und* dunklem Grund mindestens 3:1 erreichen — sonst der geprüfte
Standardton.
- **7 weitere Tests** für Gesamtzahlen, Monatsgruppierung, Jahresrückblick,
„Weiterschauen" und den Kalenderzeitraum.
### Behoben
- Das Monatsdiagramm blieb leer, obwohl Sichtungen vorlagen: `date_trunc`
liefert Mitternacht in der Zeitzone der Datenbanksitzung, JavaScript legte
beim Abgleich die lokale Zone an — zwei Stunden Versatz genügten, damit kein
einziger Monatsschlüssel mehr passte. Der Schlüssel kommt jetzt als Text
(„2026-04") aus der Datenbank.
- Eine laufende Serie, bei der man auf dem Stand ist, gilt als „abgeschlossen"
und verschwand damit aus dem Kalender — dabei will man die nächste Folge
dort erst recht sehen. Jetzt fällt nur Abgebrochenes heraus.
- Der SVG-Titel des Diagramms brach die Hydration: React behandelt `<title>`
als Dokument-Metadatum und hebt es aus dem SVG heraus. Die Beschriftung
hängt jetzt als `aria-label` am `<svg>`.
## [0.4.0] — 2026-09-01
Universen: zusammengehörige Titel, mehrere Reihenfolgen, ein Gesamtfortschritt.
### Hinzugefügt
- **Universen** aus beliebigen Filmen **und** Serien — MCU, Star Wars,
Wizarding World oder was auch immer zusammengehört.
- **Mehrere Reihenfolgen je Universum**. Die Erscheinungsreihenfolge wird aus
den Erscheinungsdaten berechnet und nie gespeichert — sie stimmt damit immer.
Weitere Reihenfolgen (Story-Chronologie, Einsteiger-Route) sind frei
sortierbar, per Ziehen **und** über Pfeilknöpfe. Reines Drag & Drop wäre mit
Tastatur nicht bedienbar und auf dem Handy fummelig.
- **TMDB-Collections als Startpunkt**: Reihen, die in der eigenen Bibliothek
vorkommen, lassen sich mit einem Klick übernehmen.
- **Universums-Detailseite** mit nummerierter Liste, Gesamtfortschritt
(„4 von 7 abgeschlossen, 21 h 21 min gesehen"), hervorgehobenem
„Als Nächstes dran" und der Restlaufzeit bis zum Ende des Universums.
Bei Serien schrumpft die Restzeit anteilig mit den gesehenen Episoden.
- **Teilen** mit anderen Konten der Instanz, wahlweise nur zum Ansehen oder
zum gemeinsamen Pflegen. Der Fortschritt ist dabei immer der des
jeweiligen Betrachters — ein geteiltes Universum zeigt jeder Person ihren
eigenen Stand. Löschen bleibt dem Besitzer vorbehalten.
- **12 weitere Tests** für Reihenfolgen, Fortschrittsberechnung über Filme und
Serien hinweg, Freigaben und die Rechteprüfung.
- Beispieluniversum im Seed: die Sternenpfad-Reihe mit fünf Filmen, zwei
Serien und einer abweichenden Story-Chronologie.
### Behoben
- Die Posterkachel zog über `lib/images.ts` die serverseitige Cache-Logik in
den Browser-Bundle, sobald sie aus einer Client-Komponente heraus gerendert
wurde — der Build brach ab, sobald eine solche Seite dazukam. Die reine
URL-Bildung liegt jetzt in `lib/image-url.ts`, der Dateicache bleibt
serverseitig.
## [0.3.0] — 2026-09-01 ## [0.3.0] — 2026-09-01
Das Kernstück: Bibliothek, Status und Episodenfortschritt. Das Kernstück: Bibliothek, Status und Episodenfortschritt.
@@ -132,7 +214,9 @@ Erste lauffähige Etappe: Gerüst, Marke, Datenmodell und Anmeldung.
dass weitere Sprachen als zusätzliche Datei dazukommen können. dass weitere Sprachen als zusätzliche Datei dazukommen können.
- **Seed-Skript** mit Demo-Konten sowie README und Design-Dokumentation. - **Seed-Skript** mit Demo-Konten sowie README und Design-Dokumentation.
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.3.0...HEAD [Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.5.0...HEAD
[0.5.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.5.0
[0.4.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.4.0
[0.3.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.3.0 [0.3.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.3.0
[0.2.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.2.0 [0.2.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.2.0
[0.1.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.1.0 [0.1.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.1.0
+29 -1
View File
@@ -49,6 +49,32 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
--- ---
## Etappe 4 — Universen mit Reihenfolgen und Fortschritt (v0.4.0)
| # | Start | Ende | Dauer | Arbeitsschritt |
|---|---|---|---|---|
| 4.1 | 19:25 | 19:30 | 5 min | Domänenlogik: Universen, berechnete Erscheinungsreihenfolge, frei sortierbare Reihenfolgen, Fortschritt aus Sicht des Betrachters, Freigaben |
| 4.2 | 19:30 | 19:33 | 3 min | 12 Tests dazu; eine falsche Testannahme zur Reihenfolge korrigiert (nicht den Code) |
| 4.3 | 19:33 | 19:36 | 3 min | Server-Aktionen, sortierbare Liste mit Ziehen und Pfeilknöpfen, Reihenfolge-Umschalter, Aufnehmen, Teilen |
| 4.4 | 19:36 | 19:39 | 3 min | Seiten für Übersicht, Anlegen und Detailansicht; Build-Fehler durch `server-only` im Browser-Bundle gefunden und behoben; Umsortieren im Browser geprüft |
**Etappe 4 gesamt: 14 min** (2026-09-01, 19:25 – 19:39)
---
## Etappe 5 — Dashboard, Kalender, Statistiken (v0.5.0)
| # | Start | Ende | Dauer | Arbeitsschritt |
|---|---|---|---|---|
| 5.1 | 19:39 | 19:43 | 4 min | Auswertungsschicht: Weiterschauen, kommende Episoden, neu verfügbar, Gesamtzahlen, Monatsverlauf, Verteilungen, Jahresrückblick |
| 5.2 | 19:43 | 19:46 | 3 min | Diagrammfarben gegen die Prüfungen auf Helligkeit, Sättigung, Farbfehlsichtigkeit und Kontrast gerechnet; SVG-Diagramme und Verteilungsbalken gebaut |
| 5.3 | 19:46 | 19:50 | 4 min | Seiten für Übersicht, Kalender und Statistik |
| 5.4 | 19:50 | 19:54 | 4 min | Drei Fehler gefunden und behoben: leeres Monatsdiagramm durch Zeitzonenversatz, Hydrationsbruch durch SVG-Titel, Kalender ohne abgeschlossene Serien; 7 Tests nachgezogen |
**Etappe 5 gesamt: 15 min** (2026-09-01, 19:39 – 19:54)
---
## Gesamtübersicht ## Gesamtübersicht
| Etappe | Version | Dauer | | Etappe | Version | Dauer |
@@ -56,5 +82,7 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
| 1 — Gerüst, Branding, Docker, Schema, Auth | 0.1.0 | 38 min | | 1 — Gerüst, Branding, Docker, Schema, Auth | 0.1.0 | 38 min |
| 2 — TMDB-Anbindung | 0.2.0 | 14 min | | 2 — TMDB-Anbindung | 0.2.0 | 14 min |
| 3 — Kern-Tracking | 0.3.0 | 24 min | | 3 — Kern-Tracking | 0.3.0 | 24 min |
| 4 — Universen | 0.4.0 | 14 min |
| 5 — Dashboard, Kalender, Statistik | 0.5.0 | 15 min |
**Bisher gesamt: 76 min** **Bisher gesamt: 105 min**
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "flimmer", "name": "flimmer",
"version": "0.3.0", "version": "0.5.0",
"private": true, "private": true,
"description": "Flimmer — selbst hostbarer Serien- und Film-Tracker", "description": "Flimmer — selbst hostbarer Serien- und Film-Tracker",
"license": "MIT", "license": "MIT",
+76
View File
@@ -458,6 +458,73 @@ async function seedLibrary(userId: string, titleIds: Map<string, string>) {
} }
} }
// ---------------------------------------------------------------------------
// Universum
// ---------------------------------------------------------------------------
/**
* Die Erscheinungsreihenfolge berechnet Flimmer selbst — hier steht nur die
* abweichende Story-Chronologie, damit die Seite zwei echte Reihenfolgen zeigt.
*/
const CHRONOLOGICAL = [
"sternenpfad-null",
"sternenpfad-1",
"sternenpfad-akademie",
"sternenpfad-2",
"sternenpfad-serie",
"sternenpfad-3",
"sternenpfad-4",
];
async function seedUniverse(ownerId: string, titleIds: Map<string, string>) {
const existing = await prisma.universe.findUnique({
where: { ownerId_slug: { ownerId, slug: "sternenpfad" } },
select: { id: true },
});
if (existing) {
await prisma.universe.delete({ where: { id: existing.id } });
}
const universe = await prisma.universe.create({
data: {
ownerId,
name: "Sternenpfad",
slug: "sternenpfad",
description:
"Fünf Filme und zwei Serien. Die Kinoreihenfolge und die Story-Chronologie gehen auseinander — deshalb zwei Reihenfolgen.",
orders: {
create: [{ name: "Erscheinungsreihenfolge", kind: "RELEASE", isDefault: true }],
},
},
select: { id: true },
});
const itemIdByKey = new Map<string, string>();
for (const key of CHRONOLOGICAL) {
const titleId = titleIds.get(key);
if (!titleId) continue;
const item = await prisma.universeItem.create({
data: { universeId: universe.id, titleId },
select: { id: true },
});
itemIdByKey.set(key, item.id);
}
const order = await prisma.universeOrder.create({
data: { universeId: universe.id, name: "Chronologisch", kind: "CUSTOM" },
select: { id: true },
});
await prisma.universeOrderItem.createMany({
data: CHRONOLOGICAL.flatMap((key, index) => {
const itemId = itemIdByKey.get(key);
return itemId ? [{ orderId: order.id, itemId, position: index }] : [];
}),
});
return universe;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function main() { async function main() {
@@ -509,6 +576,15 @@ async function main() {
console.log("Seed: Bibliothek und Sichtungen ..."); console.log("Seed: Bibliothek und Sichtungen ...");
await seedLibrary(admin.id, titleIds); await seedLibrary(admin.id, titleIds);
console.log("Seed: Universum ...");
const universe = await seedUniverse(admin.id, titleIds);
// Mit dem zweiten Konto teilen, damit die Freigabe sichtbar ist.
await prisma.universeMember.upsert({
where: { universeId_userId: { universeId: universe.id, userId: friend.id } },
create: { universeId: universe.id, userId: friend.id, permission: "READ" },
update: {},
});
const watches = await prisma.watch.count({ where: { userId: admin.id } }); const watches = await prisma.watch.count({ where: { userId: admin.id } });
const entries = await prisma.libraryEntry.count({ where: { userId: admin.id } }); const entries = await prisma.libraryEntry.count({ where: { userId: admin.id } });
+35
View File
@@ -0,0 +1,35 @@
# Flimmer 0.4.0
Universen: zusammengehörige Titel, mehrere Reihenfolgen, ein Gesamtfortschritt.
### Hinzugefügt
- **Universen** aus beliebigen Filmen **und** Serien — MCU, Star Wars,
Wizarding World oder was auch immer zusammengehört.
- **Mehrere Reihenfolgen je Universum**. Die Erscheinungsreihenfolge wird aus
den Erscheinungsdaten berechnet und nie gespeichert — sie stimmt damit immer.
Weitere Reihenfolgen (Story-Chronologie, Einsteiger-Route) sind frei
sortierbar, per Ziehen **und** über Pfeilknöpfe. Reines Drag & Drop wäre mit
Tastatur nicht bedienbar und auf dem Handy fummelig.
- **TMDB-Collections als Startpunkt**: Reihen, die in der eigenen Bibliothek
vorkommen, lassen sich mit einem Klick übernehmen.
- **Universums-Detailseite** mit nummerierter Liste, Gesamtfortschritt
(„4 von 7 abgeschlossen, 21 h 21 min gesehen"), hervorgehobenem
„Als Nächstes dran" und der Restlaufzeit bis zum Ende des Universums.
Bei Serien schrumpft die Restzeit anteilig mit den gesehenen Episoden.
- **Teilen** mit anderen Konten der Instanz, wahlweise nur zum Ansehen oder
zum gemeinsamen Pflegen. Der Fortschritt ist dabei immer der des
jeweiligen Betrachters — ein geteiltes Universum zeigt jeder Person ihren
eigenen Stand. Löschen bleibt dem Besitzer vorbehalten.
- **12 weitere Tests** für Reihenfolgen, Fortschrittsberechnung über Filme und
Serien hinweg, Freigaben und die Rechteprüfung.
- Beispieluniversum im Seed: die Sternenpfad-Reihe mit fünf Filmen, zwei
Serien und einer abweichenden Story-Chronologie.
### Behoben
- Die Posterkachel zog über `lib/images.ts` die serverseitige Cache-Logik in
den Browser-Bundle, sobald sie aus einer Client-Komponente heraus gerendert
wurde — der Build brach ab, sobald eine solche Seite dazukam. Die reine
URL-Bildung liegt jetzt in `lib/image-url.ts`, der Dateicache bleibt
serverseitig.
+45
View File
@@ -0,0 +1,45 @@
# Flimmer 0.5.0
Übersicht, Kalender und Statistik.
### Hinzugefügt
- **Übersicht** mit „Weiterschauen" (laufende Serien samt nächster Episode,
Fortschritt und „+1 Episode" direkt aus der Kachel), den Ausstrahlungen der
nächsten vier Wochen und den Watchlist-Titeln, die zuletzt auf einem Dienst
verfügbar geworden sind.
- **Kalender** als Monatsraster mit Blättern und Sprung zum heutigen Tag,
darunter die Termine des Monats als Liste. Ausgezeichnet als Tabelle — ein
Kalender *ist* eine Tabelle, und mit Screenreader wird daraus dann
„Woche 3, Donnerstag, 24." statt einer ununterscheidbaren Kachelwüste.
- **Statistikseite** mit Gesamtschauzeit, Episoden und Filmen je Monat als
Diagramm, Verteilung nach Streamingdienst und Genre, Jahresrückblick
(„2026 in Zahlen") sowie den Listen „wo die meiste Zeit hinging" und
„deine besten Bewertungen". Alles aus den Sichtungen gerechnet, Rewatches
zählen mit.
- **Diagramme als handgeschriebenes SVG** — ohne Chart-Bibliothek, dafür an
den Design-Tokens. Die beiden Serienfarben sind abgedunkelte Varianten der
Signaturfarben; sie bestehen in **beiden** Farbschemata die Prüfungen auf
Helligkeitsband, Sättigung, Farbfehlsichtigkeit und Kontrast. Die vollen
Signaturtöne wären auf dunklem Grund zu hell. Legende, Achsenwerte,
Tooltip beim Überfahren und eine für Screenreader hinterlegte Tabelle
sorgen dafür, dass die Aussage nie allein an der Farbe hängt.
- Markenfarben der Streamingdienste in der Verteilung, aber nur, wenn sie auf
hellem *und* dunklem Grund mindestens 3:1 erreichen — sonst der geprüfte
Standardton.
- **7 weitere Tests** für Gesamtzahlen, Monatsgruppierung, Jahresrückblick,
„Weiterschauen" und den Kalenderzeitraum.
### Behoben
- Das Monatsdiagramm blieb leer, obwohl Sichtungen vorlagen: `date_trunc`
liefert Mitternacht in der Zeitzone der Datenbanksitzung, JavaScript legte
beim Abgleich die lokale Zone an — zwei Stunden Versatz genügten, damit kein
einziger Monatsschlüssel mehr passte. Der Schlüssel kommt jetzt als Text
(„2026-04") aus der Datenbank.
- Eine laufende Serie, bei der man auf dem Stand ist, gilt als „abgeschlossen"
und verschwand damit aus dem Kalender — dabei will man die nächste Folge
dort erst recht sehen. Jetzt fällt nur Abgebrochenes heraus.
- Der SVG-Titel des Diagramms brach die Hydration: React behandelt `<title>`
als Dokument-Metadatum und hebt es aus dem SVG heraus. Die Beschriftung
hängt jetzt als `aria-label` am `<svg>`.
+20
View File
@@ -0,0 +1,20 @@
import { chromium } from "playwright";
const [,, SP, ...specs] = process.argv;
const b = await chromium.launch();
const c = await b.newContext({ viewport: { width: 1280, height: 950 }, deviceScaleFactor: 2, colorScheme: "dark" });
const p = await c.newPage();
const errs = [];
p.on("pageerror", e => errs.push(String(e).slice(0,200)));
p.on("console", m => { if (m.type()==="error") errs.push("console: "+m.text().slice(0,160)); });
await p.goto("http://localhost:3011/anmelden");
await p.fill("#email", "demo@flimmer.local");
await p.fill("#password", "flimmer-demo-2026");
await Promise.all([p.waitForURL("http://localhost:3011/"), p.click("button[type=submit]")]);
for (const spec of specs) {
const [name, url, full] = spec.split("|");
await p.goto(`http://localhost:3011${url}`, { waitUntil: "networkidle" });
await p.waitForTimeout(900);
await p.screenshot({ path: `${SP}/${name}.png`, fullPage: full === "full" });
}
console.log("Fehler:", errs.length ? errs.slice(0,4) : "keine");
await b.close();
+155
View File
@@ -0,0 +1,155 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { getSessionUser } from "@/lib/auth";
import {
addCollection,
addTitle,
createOrder,
createUniverse,
deleteOrder,
removeTitle,
renameOrder,
renameUniverse,
setOrderPositions,
shareUniverse,
trashUniverse,
unshareUniverse,
UniverseError,
} from "@/lib/universes";
/** Server-Aktionen für Universen. Zugriffsrechte prüft die Domänenschicht. */
export type UniverseActionResult = { ok: boolean; message?: string; id?: string };
async function run(
fn: (userId: string) => Promise<{ message?: string; id?: string } | void>,
): Promise<UniverseActionResult> {
const user = await getSessionUser();
if (!user) return { ok: false, message: "Bitte melde dich an." };
try {
const result = (await fn(user.id)) ?? {};
revalidatePath("/universen", "layout");
revalidatePath("/", "layout");
return { ok: true, message: result.message, id: result.id };
} catch (error) {
if (error instanceof UniverseError) return { ok: false, message: error.message };
console.error("Universums-Aktion fehlgeschlagen:", error);
return { ok: false, message: "Das hat nicht geklappt. Bitte noch einmal versuchen." };
}
}
const id = z.string().min(1).max(64);
const name = z.string().trim().min(1).max(120);
export async function createUniverseAction(rawName: string, description?: string) {
return run(async (userId) => {
const universe = await createUniverse(userId, name.parse(rawName), description);
return { message: `„${universe.name}" angelegt`, id: universe.id };
});
}
export async function renameUniverseAction(
universeId: string,
data: { name?: string; description?: string | null },
) {
return run(async (userId) => {
await renameUniverse(userId, id.parse(universeId), {
...(data.name !== undefined ? { name: name.parse(data.name) } : {}),
...(data.description !== undefined
? { description: z.string().max(2000).nullable().parse(data.description) }
: {}),
});
return { message: "Gespeichert" };
});
}
export async function trashUniverseAction(universeId: string) {
return run(async (userId) => {
await trashUniverse(userId, id.parse(universeId));
return { message: "Universum gelöscht" };
});
}
export async function addTitleToUniverseAction(universeId: string, titleId: string) {
return run(async (userId) => {
await addTitle(userId, id.parse(universeId), id.parse(titleId));
return { message: "Aufgenommen" };
});
}
export async function removeFromUniverseAction(universeId: string, itemId: string) {
return run(async (userId) => {
await removeTitle(userId, id.parse(universeId), id.parse(itemId));
return { message: "Aus dem Universum entfernt" };
});
}
export async function addCollectionAction(universeId: string, tmdbCollectionId: number) {
return run(async (userId) => {
const added = await addCollection(
userId,
id.parse(universeId),
z.number().int().parse(tmdbCollectionId),
);
return {
message:
added === 0
? "Alles daraus war schon drin"
: `${added} ${added === 1 ? "Titel" : "Titel"} übernommen`,
};
});
}
export async function createOrderAction(universeId: string, rawName: string) {
return run(async (userId) => {
const order = await createOrder(userId, id.parse(universeId), name.parse(rawName));
return { message: "Reihenfolge angelegt", id: order.id };
});
}
export async function renameOrderAction(orderId: string, rawName: string) {
return run(async (userId) => {
await renameOrder(userId, id.parse(orderId), name.parse(rawName));
return { message: "Umbenannt" };
});
}
export async function deleteOrderAction(orderId: string) {
return run(async (userId) => {
await deleteOrder(userId, id.parse(orderId));
return { message: "Reihenfolge gelöscht" };
});
}
export async function reorderAction(orderId: string, itemIds: string[]) {
return run(async (userId) => {
await setOrderPositions(userId, id.parse(orderId), z.array(id).max(1000).parse(itemIds));
return { message: "Reihenfolge gespeichert" };
});
}
export async function shareUniverseAction(
universeId: string,
email: string,
permission: "READ" | "WRITE",
) {
return run(async (userId) => {
const target = await shareUniverse(
userId,
id.parse(universeId),
z.string().trim().email().parse(email),
z.enum(["READ", "WRITE"]).parse(permission),
);
return { message: `Mit ${target.name} geteilt` };
});
}
export async function unshareUniverseAction(universeId: string, memberId: string) {
return run(async (userId) => {
await unshareUniverse(userId, id.parse(universeId), id.parse(memberId));
return { message: "Freigabe aufgehoben" };
});
}
+127
View File
@@ -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 (
<AppShell user={user} instanceName={settings.instanceName}>
<header className="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Kalender</h1>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
Ausstrahlungstermine der Serien in deiner Bibliothek.
</p>
</div>
<nav className="flex items-center gap-2" aria-label="Monat wechseln">
<Link
href={`/kalender?monat=${monthParam(previous)}`}
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
aria-label={`Zum ${previous.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}`}
>
←
</Link>
<span className="min-w-40 text-center font-medium">
{month.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}
</span>
<Link
href={`/kalender?monat=${monthParam(next)}`}
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
aria-label={`Zum ${next.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}`}
>
→
</Link>
<Link href="/kalender" className="ml-1 text-sm text-[var(--fl-faint)] hover:text-[var(--fl-text)]">
Heute
</Link>
</nav>
</header>
<div className="fl-card overflow-x-auto p-3 sm:p-4">
<div className="min-w-[44rem]">
<CalendarMonth month={month} episodes={episodes} />
</div>
</div>
<section className="mt-6" aria-labelledby="liste">
<h2 id="liste" className="mb-3 text-lg font-semibold">
Termine im {month.toLocaleDateString("de-DE", { month: "long" })}
</h2>
{inMonth.length === 0 ? (
<div className="fl-card p-6 text-center text-sm text-[var(--fl-muted)]">
In diesem Monat läuft nichts aus deiner Bibliothek.
</div>
) : (
<ol className="fl-card divide-y divide-[var(--fl-border)]">
{inMonth.map((episode) => (
<li key={episode.episodeId} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3">
<span className="w-52 shrink-0 text-sm text-[var(--fl-faint)]">
{formatDateLong(episode.airDate)}
</span>
<Link
href={`/titel/${episode.titleId}`}
className="min-w-0 flex-1 text-sm hover:text-[var(--fl-accent)]"
>
<span className="font-medium">{episode.title}</span>
<span className="ml-2 font-mono text-xs text-[var(--fl-faint)]">
{episodeCode(episode.seasonNumber, episode.episodeNumber)}
</span>
<span className="ml-2 text-[var(--fl-muted)]">{episode.name}</span>
</Link>
{episode.runtime && (
<span className="shrink-0 text-xs text-[var(--fl-faint)]">{episode.runtime} min</span>
)}
</li>
))}
</ol>
)}
</section>
</AppShell>
);
}
+190 -44
View File
@@ -2,21 +2,196 @@ import Link from "next/link";
import { requireUser } from "@/lib/auth"; import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings"; import { getSettings } from "@/lib/settings";
import { env } from "@/lib/env"; 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 { 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 * Drei Fragen, drei Bereiche — weiterschauen, was demnächst kommt, was auf der
* Schritte; ab Etappe 5 ziehen „Weiterschauen", Kalender und Neuzugänge ein. * Watchlist verfügbar geworden ist.
*/ */
export default async function DashboardPage() { export default async function DashboardPage() {
const user = await requireUser(); const user = await requireUser();
const settings = await getSettings(); 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 (
<AppShell user={user} instanceName={settings.instanceName}> <AppShell user={user} instanceName={settings.instanceName}>
<EmptyStart tmdbReady={env.tmdb.isConfigured} name={user.name} />
</AppShell>
);
}
return (
<AppShell user={user} instanceName={settings.instanceName}>
<h1 className="sr-only">Übersicht</h1>
<section aria-labelledby="weiterschauen">
<div className="mb-3 flex items-baseline justify-between gap-3">
<h2 id="weiterschauen" className="text-lg font-semibold">
Weiterschauen
</h2>
<Link href="/bibliothek?status=WATCHING" className="text-sm text-[var(--fl-faint)] hover:text-[var(--fl-text)]">
Alle laufenden
</Link>
</div>
{continueItems.length === 0 ? (
<div className="fl-card p-6 text-center text-sm text-[var(--fl-muted)]">
Gerade läuft nichts. Setz eine Serie auf „Schaue gerade", dann steht sie hier.
</div>
) : (
<ul className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{continueItems.map((item) => (
<li key={item.entryId} className="fl-card flex gap-3 p-3">
<Link href={`/titel/${item.titleId}`} className="w-16 shrink-0 sm:w-20">
<Poster path={item.posterPath} alt={item.title} size="w185" />
</Link>
<div className="flex min-w-0 flex-1 flex-col">
<Link href={`/titel/${item.titleId}`} className="truncate font-medium hover:text-[var(--fl-accent)]">
{item.title}
</Link>
{item.next && (
<p className="mt-0.5 truncate text-sm">
<span className="fl-gradient-text font-mono font-semibold">
{episodeCode(item.next.seasonNumber, item.next.episodeNumber)}
</span>{" "}
<span className="text-[var(--fl-muted)]">{item.next.name}</span>
</p>
)}
<p className="mt-0.5 text-xs text-[var(--fl-faint)]">
{item.watched} von {item.total} Episoden
{item.lastWatchedAt ? ` · zuletzt ${formatRelative(item.lastWatchedAt)}` : ""}
</p>
<div className="mt-2">
<ProgressBar
percent={item.percent}
label={`${item.watched} von ${item.total} Episoden gesehen`}
/>
</div>
<div className="mt-2.5">
{item.next && isUpcoming(item.next.airDate) ? (
<p className="text-xs text-[var(--fl-faint)]">
Nächste Episode erst am {formatDate(item.next.airDate)}
</p>
) : (
<QuickWatch entryId={item.entryId} />
)}
</div>
</div>
</li>
))}
</ul>
)}
</section>
<div className="mt-8 grid gap-6 lg:grid-cols-2">
<section aria-labelledby="demnaechst">
<div className="mb-3 flex items-baseline justify-between gap-3">
<h2 id="demnaechst" className="text-lg font-semibold">
Demnächst
</h2>
<Link href="/kalender" className="text-sm text-[var(--fl-faint)] hover:text-[var(--fl-text)]">
Zum Kalender
</Link>
</div>
<div className="fl-card p-4">
{upcoming.length === 0 ? (
<p className="text-sm text-[var(--fl-muted)]">
In den nächsten vier Wochen ist keine Episode deiner Serien angekündigt.
</p>
) : (
<ol className="space-y-2.5">
{upcoming.slice(0, 6).map((episode) => (
<li key={episode.episodeId} className="flex items-center gap-3">
<span className="w-20 shrink-0 text-xs text-[var(--fl-faint)]">
{formatDate(episode.airDate)}
</span>
<Link
href={`/titel/${episode.titleId}`}
className="min-w-0 flex-1 truncate text-sm hover:text-[var(--fl-accent)]"
>
<span className="font-medium">{episode.title}</span>
<span className="ml-1.5 font-mono text-xs text-[var(--fl-faint)]">
{episodeCode(episode.seasonNumber, episode.episodeNumber)}
</span>
</Link>
</li>
))}
</ol>
)}
</div>
</section>
<section aria-labelledby="verfuegbar">
<h2 id="verfuegbar" className="mb-3 text-lg font-semibold">
Neu verfügbar
</h2>
<div className="fl-card p-4">
{available.length === 0 ? (
<p className="text-sm text-[var(--fl-muted)]">
Auf deiner Watchlist ist zuletzt nichts neu dazugekommen. Sobald ein Titel auf
einem Dienst in {user.region} auftaucht, steht er hier.
</p>
) : (
<ul className="space-y-3">
{available.slice(0, 5).map((item) => (
<li key={item.titleId} className="flex items-center gap-3">
<Link href={`/titel/${item.titleId}`} className="w-10 shrink-0">
<Poster path={item.posterPath} alt={item.title} size="w154" />
</Link>
<div className="min-w-0 flex-1">
<Link
href={`/titel/${item.titleId}`}
className="block truncate text-sm font-medium hover:text-[var(--fl-accent)]"
>
{item.title}
</Link>
<p className="truncate text-xs text-[var(--fl-faint)]">
{item.providers.map((provider) => provider.name).join(", ")} ·{" "}
{formatRelative(item.since)}
</p>
</div>
</li>
))}
</ul>
)}
</div>
</section>
</div>
</AppShell>
);
}
function EmptyStart({ name, tmdbReady }: { name: string; tmdbReady: boolean }) {
return (
<section className="fl-card relative overflow-hidden p-6 sm:p-10"> <section className="fl-card relative overflow-hidden p-6 sm:p-10">
<div <div
aria-hidden aria-hidden
@@ -24,60 +199,31 @@ export default async function DashboardPage() {
style={{ backgroundImage: "var(--fl-gradient)" }} style={{ backgroundImage: "var(--fl-gradient)" }}
/> />
<div className="relative"> <div className="relative">
<FlimmerMark className="h-12 w-12" /> <h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">
<h1 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl"> Hallo {name.split(" ")[0]}.
Hallo {user.name.split(" ")[0]}.
</h1> </h1>
<p className="mt-2 max-w-xl text-[var(--fl-muted)]"> <p className="mt-2 max-w-xl text-[var(--fl-muted)]">
Schön, dass du da bist. Sobald du deinen ersten Titel hinzufügst, findest du hier Deine Bibliothek ist noch leer. Such dir eine Serie oder einen Film — danach findest du
deine laufenden Serien, die nächsten Episoden und was auf deiner Watchlist gerade hier, was als Nächstes dran ist, welche Episoden demnächst kommen und was auf deiner
verfügbar geworden ist. Watchlist verfügbar geworden ist.
</p> </p>
<div className="mt-6 flex flex-wrap gap-3"> <div className="mt-6 flex flex-wrap gap-3">
<Link href="/suche" className="fl-btn fl-btn-primary"> <Link href="/suche" className="fl-btn fl-btn-primary">
Titel suchen und hinzufügen Titel suchen
</Link> </Link>
<Link href="/bibliothek" className="fl-btn fl-btn-ghost"> <Link href="/titel/neu" className="fl-btn fl-btn-ghost">
Zur Bibliothek Titel selbst anlegen
</Link> </Link>
</div> </div>
{!env.tmdb.isConfigured && ( {!tmdbReady && (
<p className="mt-6 max-w-xl rounded-xl border border-[color-mix(in_oklab,var(--fl-warning)_40%,transparent)] bg-[color-mix(in_oklab,var(--fl-warning)_10%,transparent)] px-3 py-2 text-sm text-[var(--fl-warning)]"> <p className="mt-6 max-w-xl rounded-xl border border-[color-mix(in_oklab,var(--fl-warning)_40%,transparent)] bg-[color-mix(in_oklab,var(--fl-warning)_10%,transparent)] px-3 py-2 text-sm text-[var(--fl-warning)]">
Es ist noch kein TMDB-Schlüssel hinterlegt. Ohne ihn findet die Suche keine Es ist noch kein TMDB-Schlüssel hinterlegt. Ohne ihn findet die Suche keine Metadaten —
Metadaten — trage <code>TMDB_API_KEY</code> in der <code>.env</code> ein und starte trage <code>TMDB_API_KEY</code> in der <code>.env</code> ein und starte die Instanz neu.
die Instanz neu.
</p> </p>
)} )}
</div> </div>
</section> </section>
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<PlaceholderCard
title="Weiterschauen"
text="Deine laufenden Serien mit der jeweils nächsten Episode."
/>
<PlaceholderCard
title="Demnächst"
text="Kalender der Episoden, die in den nächsten Wochen ausgestrahlt werden."
/>
<PlaceholderCard
title="Neu verfügbar"
text="Watchlist-Titel, die auf einem deiner Dienste aufgetaucht sind."
/>
</div>
</AppShell>
);
}
function PlaceholderCard({ title, text }: { title: string; text: string }) {
return (
<div className="fl-card p-5">
<h2 className="text-sm font-semibold tracking-wide text-[var(--fl-muted)] uppercase">
{title}
</h2>
<p className="mt-2 text-sm text-[var(--fl-faint)]">{text}</p>
</div>
); );
} }
+267
View File
@@ -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>
);
}
+193
View File
@@ -0,0 +1,193 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { prisma } from "@/lib/db";
import { getUniverse, suggestCollections } from "@/lib/universes";
import { formatMinutes } from "@/lib/labels";
import { AppShell } from "@/components/AppShell";
import { ProgressBar } from "@/components/ProgressBar";
import { OrderedList } from "@/components/universes/OrderedList";
import {
AddTitles,
DeleteUniverse,
OrderTabs,
SharePanel,
} from "@/components/universes/UniverseTools";
type Params = Promise<{ id: string }>;
type Search = Promise<{ reihenfolge?: string }>;
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { id } = await params;
const universe = await prisma.universe.findUnique({ where: { id }, select: { name: true } });
return { title: universe?.name ?? "Universum" };
}
export default async function UniversePage({
params,
searchParams,
}: {
params: Params;
searchParams: Search;
}) {
const user = await requireUser();
const settings = await getSettings();
const { id } = await params;
const { reihenfolge } = await searchParams;
const universe = await getUniverse(user.id, id, reihenfolge);
if (!universe) notFound();
const activeOrder = universe.orders.find((order) => order.id === universe.activeOrderId);
const inUniverse = new Set(universe.entries.map((entry) => entry.titleId));
// Vorschläge zum Aufnehmen: alles aus der eigenen Bibliothek, was noch fehlt.
const [libraryTitles, collections] = universe.canWrite
? await Promise.all([
prisma.libraryEntry.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { title: { title: "asc" } },
select: {
title: { select: { id: true, title: true, type: true, releaseDate: true } },
},
}),
suggestCollections(user.id),
])
: [[], []];
const candidates = libraryTitles
.map((entry) => entry.title)
.filter((title) => !inUniverse.has(title.id))
.map((title) => ({
id: title.id,
title: title.title,
type: title.type,
year: title.releaseDate?.getFullYear() ?? null,
}));
return (
<AppShell user={user} instanceName={settings.instanceName}>
<header className="mb-5">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Universum
{!universe.isOwner && ` · geteilt von ${universe.ownerName}`}
{!universe.canWrite && " · nur ansehen"}
</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight sm:text-3xl">{universe.name}</h1>
{universe.description && (
<p className="mt-1 max-w-2xl text-sm text-[var(--fl-muted)]">{universe.description}</p>
)}
</header>
<section className="fl-card mb-5 p-5" aria-label="Gesamtfortschritt">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-2xl font-semibold">
{universe.progress.completed}{" "}
<span className="text-base font-normal text-[var(--fl-muted)]">
von {universe.progress.total} abgeschlossen
</span>
</p>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
{formatMinutes(universe.progress.watchedMinutes)} gesehen
{universe.progress.remainingMinutes > 0 && (
<>
{" · noch "}
<span className="text-[var(--fl-text)]">
{formatMinutes(universe.progress.remainingMinutes)}
</span>
{" bis zum Ende des Universums"}
</>
)}
</p>
</div>
{universe.nextUp && (
<div className="text-right">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Als Nächstes dran
</p>
<Link
href={`/titel/${universe.nextUp.titleId}`}
className="fl-gradient-text text-lg font-semibold"
>
{universe.nextUp.title}
</Link>
</div>
)}
</div>
<div className="mt-4">
<ProgressBar
percent={universe.progress.percent}
label={`${universe.progress.percent} Prozent des Universums abgeschlossen`}
/>
</div>
</section>
<div className="mb-4">
<OrderTabs universe={universe} canWrite={universe.canWrite} />
{activeOrder?.kind === "RELEASE" ? (
<p className="mt-2 text-xs text-[var(--fl-faint)]">
Diese Reihenfolge ergibt sich aus den Erscheinungsdaten und bleibt damit immer aktuell.
</p>
) : universe.canWrite ? (
<p className="mt-2 text-xs text-[var(--fl-faint)]">
Ziehen oder die Pfeile benutzen, um umzusortieren — gespeichert wird sofort.
</p>
) : null}
</div>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
<div className="order-2 lg:order-1">
<OrderedList
universeId={universe.id}
orderId={universe.activeOrderId}
entries={universe.entries}
sortable={activeOrder?.kind === "CUSTOM"}
canWrite={universe.canWrite}
nextUpItemId={universe.nextUp?.itemId ?? null}
/>
</div>
<aside className="order-1 space-y-5 lg:order-2">
{universe.canWrite && (
<div className="fl-card p-4">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Titel aufnehmen
</h2>
<AddTitles
universeId={universe.id}
candidates={candidates}
collections={collections}
/>
</div>
)}
{universe.isOwner && (
<>
<div className="fl-card p-4">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Teilen
</h2>
<SharePanel universe={universe} />
</div>
<div className="fl-card p-4">
<DeleteUniverse universeId={universe.id} name={universe.name} />
</div>
</>
)}
</aside>
</div>
<p className="mt-8 text-sm">
<Link href="/universen" className="text-[var(--fl-faint)] hover:text-[var(--fl-muted)]">
← Alle Universen
</Link>
</p>
</AppShell>
);
}
+38
View File
@@ -0,0 +1,38 @@
import Link from "next/link";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { AppShell } from "@/components/AppShell";
import { NewUniverseForm } from "@/components/universes/UniverseTools";
export const metadata: Metadata = { title: "Neues Universum" };
export default async function NewUniversePage() {
const user = await requireUser("/universen/neu");
const settings = await getSettings();
return (
<AppShell user={user} instanceName={settings.instanceName}>
<div className="mx-auto max-w-xl">
<header className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Neues Universum</h1>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
Ein Universum bündelt Filme und Serien, die zusammengehören. Die
Erscheinungsreihenfolge ist von Anfang an dabei; weitere Reihenfolgen legst du
danach an.
</p>
</header>
<div className="fl-card p-5 sm:p-6">
<NewUniverseForm />
</div>
<p className="mt-6 text-sm">
<Link href="/universen" className="text-[var(--fl-faint)] hover:text-[var(--fl-muted)]">
← Zurück zur Übersicht
</Link>
</p>
</div>
</AppShell>
);
}
+90
View File
@@ -0,0 +1,90 @@
import Link from "next/link";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { listUniverses } from "@/lib/universes";
import { AppShell } from "@/components/AppShell";
import { Poster } from "@/components/Poster";
import { ProgressBar } from "@/components/ProgressBar";
export const metadata: Metadata = { title: "Universen" };
export default async function UniversesPage() {
const user = await requireUser("/universen");
const settings = await getSettings();
const universes = await listUniverses(user.id);
return (
<AppShell user={user} instanceName={settings.instanceName}>
<header className="mb-6 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Universen</h1>
<p className="mt-1 max-w-2xl text-sm text-[var(--fl-muted)]">
Filme und Serien, die zusammengehören — mit mehreren Reihenfolgen und einem
Gesamtfortschritt.
</p>
</div>
<Link href="/universen/neu" className="fl-btn fl-btn-primary">
Neues Universum
</Link>
</header>
{universes.length === 0 ? (
<div className="fl-card p-8 text-center">
<p className="mx-auto max-w-md text-[var(--fl-muted)]">
Noch kein Universum angelegt. Ein Universum bündelt zusammengehörige Titel — etwa alle
MCU-Filme samt Serien — und zeigt dir, was als Nächstes dran ist.
</p>
<Link href="/universen/neu" className="fl-btn fl-btn-primary mt-4">
Erstes Universum anlegen
</Link>
</div>
) : (
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{universes.map((universe) => (
<li key={universe.id}>
<Link
href={`/universen/${universe.id}`}
className="fl-card fl-poster-card block overflow-hidden p-4"
>
<div className="mb-3 flex gap-1.5">
{universe.posters.length > 0 ? (
universe.posters.map((path, index) => (
<div key={`${path}-${index}`} className="w-1/5">
<Poster path={path} alt="" size="w154" />
</div>
))
) : (
<div className="w-1/5">
<Poster path={null} alt={universe.name} size="w154" />
</div>
)}
</div>
<h2 className="font-semibold">{universe.name}</h2>
{universe.description && (
<p className="mt-1 line-clamp-2 text-sm text-[var(--fl-muted)]">
{universe.description}
</p>
)}
<p className="mt-2 text-xs text-[var(--fl-faint)]">
{universe.itemCount} {universe.itemCount === 1 ? "Titel" : "Titel"}
{!universe.isOwner ? ` · geteilt von ${universe.ownerName}` : ""}
</p>
<div className="mt-2">
<ProgressBar
percent={universe.percent}
label={`${universe.completed} von ${universe.itemCount} abgeschlossen`}
showValue
/>
</div>
</Link>
</li>
))}
</ul>
)}
</AppShell>
);
}
+138
View File
@@ -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<string, UpcomingEpisode[]>();
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 (
<table className="w-full table-fixed border-collapse">
<caption className="sr-only">
Ausstrahlungstermine im {month.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}
</caption>
<thead>
<tr>
{WEEKDAYS.map((day) => (
<th
key={day}
scope="col"
className="pb-2 text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]"
>
{day}
</th>
))}
</tr>
</thead>
<tbody>
{weeks.map((week, weekIndex) => (
<tr key={weekIndex}>
{week.map((date) => {
const inMonth = date.getMonth() === month.getMonth();
const isToday = isSameDay(date, today);
const list = byDay.get(date.toDateString()) ?? [];
return (
<td
key={date.toISOString()}
className={clsx(
"h-24 border border-[var(--fl-border)] p-1 align-top sm:h-28",
!inMonth && "opacity-40",
)}
style={
list.length > 0
? { backgroundColor: "color-mix(in oklab, var(--fl-magenta) 7%, transparent)" }
: undefined
}
>
<span
className={clsx(
"mb-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-xs tabular-nums",
isToday
? "font-semibold text-[var(--fl-on-accent)]"
: "text-[var(--fl-faint)]",
)}
style={isToday ? { backgroundImage: "var(--fl-gradient)" } : undefined}
>
{date.getDate()}
</span>
<ul className="space-y-0.5">
{list.slice(0, 3).map((episode) => (
<li key={episode.episodeId}>
<Link
href={`/titel/${episode.titleId}`}
title={`${episode.title} — ${episodeCode(episode.seasonNumber, episode.episodeNumber)} ${episode.name}`}
className="block truncate rounded px-1 py-0.5 text-[0.7rem] leading-tight text-[var(--fl-text)] transition-colors hover:bg-[var(--fl-surface-2)]"
>
<span className="font-mono text-[var(--fl-accent)]">
{episodeCode(episode.seasonNumber, episode.episodeNumber)}
</span>{" "}
{episode.title}
</Link>
</li>
))}
{list.length > 3 && (
<li className="px-1 text-[0.7rem] text-[var(--fl-faint)]">
+ {list.length - 3} weitere
</li>
)}
</ul>
</td>
);
})}
</tr>
))}
</tbody>
</table>
);
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { clsx } from "clsx"; import { clsx } from "clsx";
import { posterUrl, type ImageSize } from "@/lib/images"; import { posterUrl, type ImageSize } from "@/lib/image-url";
import { FlimmerMark } from "@/components/brand/FlimmerLogo"; import { FlimmerMark } from "@/components/brand/FlimmerLogo";
/** /**
+1 -1
View File
@@ -1,4 +1,4 @@
import { posterUrl } from "@/lib/images"; import { posterUrl } from "@/lib/image-url";
/** /**
* Streamingdienste als Badges. * Streamingdienste als Badges.
+38
View File
@@ -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 (
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
const result = await watchNextAction(entryId);
report(result, (ids) => undo(ids));
router.refresh();
})
}
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
>
+1 Episode
</button>
);
}
+373
View File
@@ -0,0 +1,373 @@
"use client";
import { useState } from "react";
import { clsx } from "clsx";
import { formatMinutes } from "@/lib/labels";
/**
* Diagramme der Statistikseite.
*
* Handgeschriebenes SVG statt einer Chart-Bibliothek: die Diagramme sind
* schlicht, sollen sich an die Design-Tokens halten und in beiden Farbschemata
* funktionieren — dafür lohnt keine Abhängigkeit mit eigenem Versionsrhythmus.
*
* Farbwahl: Die beiden Serienfarben sind abgedunkelte Varianten der
* Signaturfarben. Sie erfüllen in **beiden** Modi die Prüfungen auf
* Helligkeitsband, Farbsättigung, Farbfehlsichtigkeit (schlechtestes Paar
* ΔE 12,1 unter Deuteranopie) und Kontrast zum Untergrund. Die vollen
* Signaturtöne wären auf dunklem Grund zu hell und fielen durch.
*/
export const SERIES_COLORS = {
episodes: "#c47a18",
movies: "#d92d78",
} as const;
/** Ein Ton für Balken, die nur eine Größe zeigen (keine Identität). */
const SINGLE_HUE = "#c47a18";
// ---------------------------------------------------------------------------
// Säulen je Monat
// ---------------------------------------------------------------------------
export type MonthlyPointData = {
label: string;
episodes: number;
movies: number;
minutes: number;
};
/** Rundet die Achsenspitze auf einen glatten Wert auf. */
function niceMax(value: number): number {
if (value <= 0) return 4;
const magnitude = 10 ** Math.floor(Math.log10(value));
for (const step of [1, 2, 2.5, 5, 10]) {
const candidate = step * magnitude;
if (candidate >= value) return candidate;
}
return 10 * magnitude;
}
export function MonthlyChart({ points }: { points: MonthlyPointData[] }) {
const [active, setActive] = useState<number | null>(null);
const max = niceMax(Math.max(1, ...points.map((point) => point.episodes + point.movies)));
const ticks = [0, max / 2, max];
// Breites Seitenverhältnis: das SVG füllt die Karte und bleibt trotzdem flach.
const width = 1100;
const height = 260;
const padding = { top: 12, right: 8, bottom: 28, left: 38 };
const plotWidth = width - padding.left - padding.right;
const plotHeight = height - padding.top - padding.bottom;
const band = plotWidth / Math.max(1, points.length);
const barWidth = Math.min(24, band - 10);
const scale = (value: number) => (value / max) * plotHeight;
const total = points.reduce((sum, point) => sum + point.episodes + point.movies, 0);
if (total === 0) {
return (
<p className="py-8 text-center text-sm text-[var(--fl-faint)]">
In den letzten zwölf Monaten wurde noch nichts abgehakt.
</p>
);
}
return (
<figure className="m-0">
<Legend
items={[
{ label: "Episoden", color: SERIES_COLORS.episodes },
{ label: "Filme", color: SERIES_COLORS.movies },
]}
/>
<div className="relative mt-2 overflow-x-auto">
<svg
viewBox={`0 0 ${width} ${height}`}
className="h-auto w-full min-w-[32rem]"
role="img"
// Bewusst aria-label statt eines <title>-Kindes: React behandelt
// <title> als Dokument-Metadatum und hebt es aus dem SVG heraus —
// das brach die Hydration.
aria-label={`Gesehene Episoden und Filme je Monat in den letzten ${points.length} Monaten`}
onMouseLeave={() => setActive(null)}
>
{ticks.map((tick) => {
const y = padding.top + plotHeight - scale(tick);
return (
<g key={tick}>
<line
x1={padding.left}
x2={width - padding.right}
y1={y}
y2={y}
stroke="var(--fl-border)"
strokeWidth="1"
/>
<text
x={padding.left - 8}
y={y + 4}
textAnchor="end"
className="fill-[var(--fl-faint)] text-[11px] tabular-nums"
>
{Math.round(tick)}
</text>
</g>
);
})}
{points.map((point, index) => {
const x = padding.left + index * band + (band - barWidth) / 2;
const episodeHeight = scale(point.episodes);
const movieHeight = scale(point.movies);
const baseline = padding.top + plotHeight;
const isActive = active === index;
return (
<g
key={point.label + index}
onMouseEnter={() => setActive(index)}
onFocus={() => setActive(index)}
tabIndex={0}
className="outline-none"
>
{/* Großzügige Trefferfläche — der Balken selbst ist schmal. */}
<rect
x={padding.left + index * band}
y={padding.top}
width={band}
height={plotHeight}
fill={isActive ? "var(--fl-surface-2)" : "transparent"}
opacity={isActive ? 0.6 : 0}
/>
{point.episodes > 0 && (
<rect
x={x}
y={baseline - episodeHeight}
width={barWidth}
height={episodeHeight}
rx={point.movies > 0 ? 0 : 4}
fill={SERIES_COLORS.episodes}
/>
)}
{point.movies > 0 && (
<rect
x={x}
// 2 px Fuge in Untergrundfarbe zwischen den Segmenten
y={baseline - episodeHeight - movieHeight - (point.episodes > 0 ? 2 : 0)}
width={barWidth}
height={movieHeight}
rx={4}
fill={SERIES_COLORS.movies}
/>
)}
<text
x={padding.left + index * band + band / 2}
y={height - 8}
textAnchor="middle"
className={clsx(
"text-[11px]",
isActive ? "fill-[var(--fl-text)]" : "fill-[var(--fl-faint)]",
)}
>
{point.label}
</text>
</g>
);
})}
</svg>
{active !== null && points[active] && (
<Tooltip
title={points[active].label}
rows={[
{ label: "Episoden", value: String(points[active].episodes), color: SERIES_COLORS.episodes },
{ label: "Filme", value: String(points[active].movies), color: SERIES_COLORS.movies },
{ label: "Schauzeit", value: formatMinutes(points[active].minutes) },
]}
position={active / Math.max(1, points.length - 1)}
/>
)}
</div>
<figcaption className="sr-only">
<table>
<caption>Gesehene Episoden und Filme je Monat</caption>
<thead>
<tr>
<th scope="col">Monat</th>
<th scope="col">Episoden</th>
<th scope="col">Filme</th>
<th scope="col">Schauzeit</th>
</tr>
</thead>
<tbody>
{points.map((point, index) => (
<tr key={point.label + index}>
<th scope="row">{point.label}</th>
<td>{point.episodes}</td>
<td>{point.movies}</td>
<td>{formatMinutes(point.minutes)}</td>
</tr>
))}
</tbody>
</table>
</figcaption>
</figure>
);
}
function Tooltip({
title,
rows,
position,
}: {
title: string;
rows: { label: string; value: string; color?: string }[];
position: number;
}) {
return (
<div
role="status"
className="pointer-events-none absolute top-0 z-10 min-w-[9rem] -translate-x-1/2 rounded-xl border border-[var(--fl-border)] bg-[var(--fl-surface-solid)] px-3 py-2 text-xs shadow-[var(--fl-shadow)]"
style={{ left: `${12 + position * 76}%` }}
>
<p className="mb-1 font-semibold">{title}</p>
{rows.map((row) => (
<p key={row.label} className="flex items-center gap-1.5 text-[var(--fl-muted)]">
{row.color && (
<span
aria-hidden
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: row.color }}
/>
)}
<span className="flex-1">{row.label}</span>
<span className="font-medium tabular-nums text-[var(--fl-text)]">{row.value}</span>
</p>
))}
</div>
);
}
function Legend({ items }: { items: { label: string; color: string }[] }) {
return (
<ul className="flex flex-wrap gap-4 text-xs text-[var(--fl-muted)]">
{items.map((item) => (
<li key={item.label} className="flex items-center gap-1.5">
<span
aria-hidden
className="h-2.5 w-2.5 rounded-sm"
style={{ backgroundColor: item.color }}
/>
{item.label}
</li>
))}
</ul>
);
}
// ---------------------------------------------------------------------------
// Liegende Balken für Verteilungen
// ---------------------------------------------------------------------------
export type DistributionRow = {
label: string;
minutes: number;
count: number;
/** Markenfarbe eines Dienstes; wird nur genutzt, wenn sie kontrastreich genug ist. */
color?: string | null;
};
/** Relative Leuchtdichte nach WCAG — für die Kontrastprüfung der Markenfarben. */
function luminance(hex: string): number | null {
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
if (!match?.[1]) return null;
const value = Number.parseInt(match[1], 16);
const channels = [(value >> 16) & 255, (value >> 8) & 255, value & 255].map((raw) => {
const c = raw / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * channels[0]! + 0.7152 * channels[1]! + 0.0722 * channels[2]!;
}
function contrast(hex: string, surfaceLuminance: number): number | null {
const value = luminance(hex);
if (value === null) return null;
const lighter = Math.max(value, surfaceLuminance);
const darker = Math.min(value, surfaceLuminance);
return (lighter + 0.05) / (darker + 0.05);
}
/** Leuchtdichte der beiden Untergründe (--fl-bg in dunkel und hell). */
const SURFACE_DARK = 0.0055;
const SURFACE_LIGHT = 0.9;
/**
* Eine Markenfarbe wird nur benutzt, wenn sie auf **beiden** Untergründen
* mindestens 3:1 erreicht. So bleibt die Wahl unabhängig vom eingestellten
* Farbschema — sonst müsste die Komponente den Theme-Wechsel mitverfolgen und
* liefe Gefahr, beim ersten Rendern etwas anderes zu zeigen als der Server.
*/
function safeBrandColor(hex: string | null | undefined): string | null {
if (!hex) return null;
const dark = contrast(hex, SURFACE_DARK);
const light = contrast(hex, SURFACE_LIGHT);
if (dark === null || light === null) return null;
return dark >= 3 && light >= 3 ? hex : null;
}
export function DistributionBars({
rows,
emptyText,
useBrandColors = false,
}: {
rows: DistributionRow[];
emptyText: string;
/**
* Streamingdienste tragen ihre Markenfarbe — das ist ihre Identität und
* zieht sich durchs ganze UI. Zu dunkle oder zu blasse Marken fallen
* automatisch auf den geprüften Standardton zurück, sonst verschwände der
* Balken je nach Farbschema.
*/
useBrandColors?: boolean;
}) {
if (rows.length === 0) {
return <p className="py-6 text-center text-sm text-[var(--fl-faint)]">{emptyText}</p>;
}
const max = Math.max(...rows.map((row) => row.minutes), 1);
return (
<ul className="space-y-2.5">
{rows.map((row) => {
const brand = useBrandColors ? safeBrandColor(row.color) : null;
const color = brand ?? SINGLE_HUE;
return (
<li key={row.label}>
<div className="mb-1 flex items-baseline justify-between gap-3 text-sm">
<span className="min-w-0 truncate text-[var(--fl-text)]">{row.label}</span>
<span className="shrink-0 tabular-nums text-[var(--fl-muted)]">
{formatMinutes(row.minutes)}
</span>
</div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-[var(--fl-surface-2)]">
<div
className="h-full rounded-full transition-[width] duration-500"
style={{
width: `${Math.max(2, (row.minutes / max) * 100)}%`,
backgroundColor: color,
}}
/>
</div>
</li>
);
})}
</ul>
);
}
+272
View File
@@ -0,0 +1,272 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { clsx } from "clsx";
import { reorderAction, removeFromUniverseAction } from "@/app/actions/universes";
import { useToasts } from "@/components/Toasts";
import { Poster } from "@/components/Poster";
import { ProgressBar } from "@/components/ProgressBar";
import { formatMinutes, STATUS_COLOR, STATUS_LABEL, type StatusKey } from "@/lib/labels";
import type { UniverseEntry } from "@/lib/universes";
/**
* Nummerierte Liste eines Universums in der gewählten Reihenfolge.
*
* Umsortieren geht per Ziehen **und** über Pfeilknöpfe. Reines Drag & Drop
* wäre mit Tastatur nicht bedienbar und auf dem Handy fummelig; die Knöpfe
* sind der verlässliche Weg, das Ziehen der bequeme.
*
* Die Erscheinungsreihenfolge (`sortable = false`) ergibt sich aus den Daten
* und lässt sich deshalb gar nicht erst anfassen.
*/
export function OrderedList({
universeId,
orderId,
entries,
sortable,
canWrite,
nextUpItemId,
}: {
universeId: string;
orderId: string;
entries: UniverseEntry[];
sortable: boolean;
canWrite: boolean;
nextUpItemId: string | null;
}) {
const router = useRouter();
const { report } = useToasts();
const [items, setItems] = useState(entries);
const [dragging, setDragging] = useState<string | null>(null);
const [, startTransition] = useTransition();
useEffect(() => setItems(entries), [entries]);
const interactive = sortable && canWrite;
function persist(next: UniverseEntry[]) {
setItems(next);
startTransition(async () => {
const result = await reorderAction(
orderId,
next.map((entry) => entry.itemId),
);
if (!result.ok) {
report(result);
setItems(entries);
}
router.refresh();
});
}
function move(index: number, delta: number) {
const target = index + delta;
if (target < 0 || target >= items.length) return;
const next = [...items];
const [moved] = next.splice(index, 1);
if (moved) next.splice(target, 0, moved);
persist(next);
}
function dropOn(targetItemId: string) {
if (!dragging || dragging === targetItemId) return;
const from = items.findIndex((entry) => entry.itemId === dragging);
const to = items.findIndex((entry) => entry.itemId === targetItemId);
if (from < 0 || to < 0) return;
const next = [...items];
const [moved] = next.splice(from, 1);
if (moved) next.splice(to, 0, moved);
setDragging(null);
persist(next);
}
if (items.length === 0) {
return (
<div className="fl-card p-8 text-center">
<p className="text-[var(--fl-muted)]">
Dieses Universum ist noch leer. Nimm Filme und Serien aus deiner Bibliothek auf.
</p>
</div>
);
}
return (
<ol className="space-y-2">
{items.map((entry, index) => (
<li
key={entry.itemId}
draggable={interactive}
onDragStart={() => setDragging(entry.itemId)}
onDragEnd={() => setDragging(null)}
onDragOver={(event) => {
if (interactive) event.preventDefault();
}}
onDrop={() => dropOn(entry.itemId)}
className={clsx(
"fl-card flex items-center gap-3 p-2.5 transition-opacity sm:gap-4 sm:p-3",
dragging === entry.itemId && "opacity-40",
entry.itemId === nextUpItemId && "ring-1",
)}
style={
entry.itemId === nextUpItemId
? {
boxShadow: "var(--fl-glow)",
borderColor: "color-mix(in oklab, var(--fl-magenta) 45%, transparent)",
}
: undefined
}
>
<span
aria-hidden
className={clsx(
"w-7 shrink-0 text-center font-mono text-sm",
entry.completed ? "text-[var(--fl-success)]" : "text-[var(--fl-faint)]",
)}
>
{entry.completed ? "✓" : index + 1}
</span>
<Link href={`/titel/${entry.titleId}`} className="w-12 shrink-0 sm:w-14">
<Poster path={entry.posterPath} alt={entry.title} size="w154" />
</Link>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<Link
href={`/titel/${entry.titleId}`}
className="font-medium hover:text-[var(--fl-accent)]"
>
{entry.title}
</Link>
{entry.itemId === nextUpItemId && (
<span
className="rounded-full px-2 py-0.5 text-[0.65rem] font-semibold text-[var(--fl-on-accent)]"
style={{ backgroundImage: "var(--fl-gradient)" }}
>
Als Nächstes dran
</span>
)}
{entry.status && (
<span
className="text-[0.65rem] font-medium"
style={{ color: STATUS_COLOR[entry.status as StatusKey] }}
>
{STATUS_LABEL[entry.status as StatusKey]}
</span>
)}
</div>
<p className="mt-0.5 text-xs text-[var(--fl-faint)]">
{entry.type === "SERIES" ? "Serie" : "Film"}
{entry.year ? ` · ${entry.year}` : ""}
{entry.type === "SERIES"
? ` · ${entry.watchedEpisodes}/${entry.totalEpisodes} Episoden`
: ""}
{entry.remainingMinutes > 0 ? ` · noch ${formatMinutes(entry.remainingMinutes)}` : ""}
</p>
{entry.type === "SERIES" && entry.totalEpisodes > 0 && (
<div className="mt-1.5 max-w-xs">
<ProgressBar
percent={(entry.watchedEpisodes / entry.totalEpisodes) * 100}
label={`${entry.watchedEpisodes} von ${entry.totalEpisodes} Episoden gesehen`}
/>
</div>
)}
</div>
{canWrite && (
<div className="flex shrink-0 items-center gap-1">
{interactive && (
<>
<IconButton
label={`${entry.title} nach oben`}
disabled={index === 0}
onClick={() => move(index, -1)}
>
<path d="m6 15 6-6 6 6" />
</IconButton>
<IconButton
label={`${entry.title} nach unten`}
disabled={index === items.length - 1}
onClick={() => move(index, 1)}
>
<path d="m6 9 6 6 6-6" />
</IconButton>
</>
)}
<RemoveButton universeId={universeId} itemId={entry.itemId} title={entry.title} />
</div>
)}
</li>
))}
</ol>
);
}
function IconButton({
label,
disabled,
onClick,
children,
}: {
label: string;
disabled?: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
className="rounded-lg p-1.5 text-[var(--fl-faint)] transition-colors hover:bg-[var(--fl-surface-2)] hover:text-[var(--fl-text)] disabled:opacity-30 disabled:hover:bg-transparent"
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
{children}
</svg>
</button>
);
}
function RemoveButton({
universeId,
itemId,
title,
}: {
universeId: string;
itemId: string;
title: string;
}) {
const router = useRouter();
const { report } = useToasts();
const [, startTransition] = useTransition();
return (
<IconButton
label={`${title} aus dem Universum entfernen`}
onClick={() =>
startTransition(async () => {
report(await removeFromUniverseAction(universeId, itemId));
router.refresh();
})
}
>
<path d="m6 6 12 12M18 6 6 18" />
</IconButton>
);
}
+401
View File
@@ -0,0 +1,401 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { clsx } from "clsx";
import {
addCollectionAction,
addTitleToUniverseAction,
createOrderAction,
createUniverseAction,
deleteOrderAction,
shareUniverseAction,
trashUniverseAction,
unshareUniverseAction,
} from "@/app/actions/universes";
import { useToasts } from "@/components/Toasts";
import type { UniverseView } from "@/lib/universes";
/** Formular für ein neues Universum. */
export function NewUniverseForm() {
const router = useRouter();
const { report } = useToasts();
const [pending, startTransition] = useTransition();
return (
<form
action={(formData) => {
const name = String(formData.get("name") ?? "").trim();
if (!name) return;
startTransition(async () => {
const result = await createUniverseAction(
name,
String(formData.get("description") ?? ""),
);
report(result);
if (result.ok && result.id) router.push(`/universen/${result.id}`);
});
}}
className="space-y-4"
>
<div className="space-y-1.5">
<label htmlFor="name" className="block text-sm font-medium text-[var(--fl-muted)]">
Name
</label>
<input
id="name"
name="name"
required
autoFocus
placeholder="MCU, Star Wars, Wizarding World …"
className="fl-input"
/>
</div>
<div className="space-y-1.5">
<label htmlFor="description" className="block text-sm font-medium text-[var(--fl-muted)]">
Beschreibung <span className="text-xs text-[var(--fl-faint)]">(optional)</span>
</label>
<textarea id="description" name="description" rows={3} className="fl-input resize-y" />
</div>
<button type="submit" disabled={pending} className="fl-btn fl-btn-primary">
{pending ? "Einen Moment …" : "Universum anlegen"}
</button>
</form>
);
}
/** Umschalter zwischen den Reihenfolgen eines Universums. */
export function OrderTabs({
universe,
canWrite,
}: {
universe: UniverseView;
canWrite: boolean;
}) {
const router = useRouter();
const params = useSearchParams();
const { report } = useToasts();
const [adding, setAdding] = useState(false);
const [, startTransition] = useTransition();
function select(orderId: string) {
const next = new URLSearchParams(params.toString());
next.set("reihenfolge", orderId);
router.replace(`/universen/${universe.id}?${next}`, { scroll: false });
}
const active = universe.orders.find((order) => order.id === universe.activeOrderId);
return (
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap gap-1.5" role="tablist" aria-label="Reihenfolge">
{universe.orders.map((order) => {
const isActive = order.id === universe.activeOrderId;
return (
<button
key={order.id}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => select(order.id)}
className={clsx(
"rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors",
isActive
? "border-transparent text-[var(--fl-on-accent)]"
: "border-[var(--fl-border)] text-[var(--fl-muted)] hover:border-[var(--fl-border-strong)] hover:text-[var(--fl-text)]",
)}
style={isActive ? { backgroundImage: "var(--fl-gradient)" } : undefined}
>
{order.name}
</button>
);
})}
</div>
{canWrite && (
<>
{adding ? (
<form
action={(formData) => {
const name = String(formData.get("orderName") ?? "").trim();
if (!name) return;
setAdding(false);
startTransition(async () => {
const result = await createOrderAction(universe.id, name);
report(result);
if (result.ok && result.id) select(result.id);
router.refresh();
});
}}
className="flex items-center gap-1.5"
>
<input
name="orderName"
autoFocus
placeholder="z. B. Chronologisch"
className="fl-input w-48 py-1.5 text-sm"
/>
<button type="submit" className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm">
Anlegen
</button>
<button
type="button"
onClick={() => setAdding(false)}
className="text-sm text-[var(--fl-faint)] hover:text-[var(--fl-text)]"
>
Abbrechen
</button>
</form>
) : (
<button
type="button"
onClick={() => setAdding(true)}
className="rounded-full border border-dashed border-[var(--fl-border-strong)] px-3.5 py-1.5 text-sm text-[var(--fl-muted)] transition-colors hover:text-[var(--fl-text)]"
>
+ Reihenfolge
</button>
)}
{active && active.kind === "CUSTOM" && (
<button
type="button"
onClick={() =>
startTransition(async () => {
report(await deleteOrderAction(active.id));
router.replace(`/universen/${universe.id}`);
router.refresh();
})
}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
>
„{active.name}" löschen
</button>
)}
</>
)}
</div>
);
}
/** Titel aus der eigenen Bibliothek aufnehmen — mit Tippfilter. */
export function AddTitles({
universeId,
candidates,
collections,
}: {
universeId: string;
candidates: { id: string; title: string; type: string; year: number | null }[];
collections: { id: number; name: string }[];
}) {
const router = useRouter();
const { report } = useToasts();
const [query, setQuery] = useState("");
const [pending, startTransition] = useTransition();
const filtered = query.trim()
? candidates
.filter((item) => item.title.toLocaleLowerCase("de-DE").includes(query.toLocaleLowerCase("de-DE")))
.slice(0, 8)
: candidates.slice(0, 8);
return (
<div className="space-y-4">
{collections.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-semibold text-[var(--fl-muted)]">
Aus einer TMDB-Reihe übernehmen
</h3>
<div className="flex flex-wrap gap-1.5">
{collections.map((collection) => (
<button
key={collection.id}
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
report(await addCollectionAction(universeId, collection.id));
router.refresh();
})
}
className="rounded-full border border-[var(--fl-border)] px-3 py-1.5 text-sm text-[var(--fl-muted)] transition-colors hover:border-[var(--fl-border-strong)] hover:text-[var(--fl-text)]"
>
{collection.name}
</button>
))}
</div>
</div>
)}
<div>
<h3 className="mb-2 text-sm font-semibold text-[var(--fl-muted)]">
Aus der Bibliothek aufnehmen
</h3>
{candidates.length === 0 ? (
<p className="text-sm text-[var(--fl-faint)]">
Alles aus deiner Bibliothek ist schon in diesem Universum.
</p>
) : (
<>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Titel filtern …"
aria-label="Titel filtern"
className="fl-input mb-2"
/>
<ul className="space-y-1">
{filtered.map((item) => (
<li key={item.id}>
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
report(await addTitleToUniverseAction(universeId, item.id));
router.refresh();
})
}
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-[var(--fl-surface-2)]"
>
<span className="min-w-0 truncate">{item.title}</span>
<span className="ml-3 shrink-0 text-xs text-[var(--fl-faint)]">
{item.type === "SERIES" ? "Serie" : "Film"}
{item.year ? ` · ${item.year}` : ""}
</span>
</button>
</li>
))}
{filtered.length === 0 && (
<li className="px-3 py-2 text-sm text-[var(--fl-faint)]">Kein Treffer.</li>
)}
</ul>
</>
)}
</div>
</div>
);
}
/** Freigabe an andere Konten der Instanz. */
export function SharePanel({ universe }: { universe: UniverseView }) {
const router = useRouter();
const { report } = useToasts();
const [permission, setPermission] = useState<"READ" | "WRITE">("READ");
const [pending, startTransition] = useTransition();
return (
<div className="space-y-3">
<form
action={(formData) => {
const email = String(formData.get("email") ?? "").trim();
if (!email) return;
startTransition(async () => {
report(await shareUniverseAction(universe.id, email, permission));
router.refresh();
});
}}
className="space-y-2"
>
<label htmlFor="share-email" className="block text-sm font-medium text-[var(--fl-muted)]">
Mit jemandem auf dieser Instanz teilen
</label>
<input
id="share-email"
name="email"
type="email"
placeholder="person@beispiel.de"
className="fl-input"
/>
<div className="flex flex-wrap items-center gap-2">
<select
value={permission}
onChange={(event) => setPermission(event.target.value as "READ" | "WRITE")}
aria-label="Recht"
className="rounded-lg border border-[var(--fl-border)] bg-[var(--fl-surface-2)] px-2.5 py-1.5 text-sm"
>
<option value="READ">Nur ansehen</option>
<option value="WRITE">Gemeinsam pflegen</option>
</select>
<button type="submit" disabled={pending} className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm">
Teilen
</button>
</div>
</form>
{universe.members.length > 0 && (
<ul className="space-y-1 border-t border-[var(--fl-border)] pt-3">
{universe.members.map((member) => (
<li key={member.id} className="flex items-center gap-2 text-sm">
<span className="min-w-0 flex-1 truncate">
{member.name}
<span className="ml-1.5 text-xs text-[var(--fl-faint)]">
{member.permission === "WRITE" ? "pflegt mit" : "liest mit"}
</span>
</span>
<button
type="button"
onClick={() =>
startTransition(async () => {
report(await unshareUniverseAction(universe.id, member.id));
router.refresh();
})
}
className="text-xs text-[var(--fl-faint)] underline-offset-2 hover:text-[var(--fl-danger)] hover:underline"
>
entfernen
</button>
</li>
))}
</ul>
)}
</div>
);
}
/** Universum löschen — mit Rückfrage. */
export function DeleteUniverse({ universeId, name }: { universeId: string; name: string }) {
const router = useRouter();
const { report } = useToasts();
const [confirming, setConfirming] = useState(false);
const [pending, startTransition] = useTransition();
if (!confirming) {
return (
<button
type="button"
onClick={() => setConfirming(true)}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
>
Universum löschen
</button>
);
}
return (
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-[var(--fl-muted)]">„{name}" wirklich löschen?</span>
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
report(await trashUniverseAction(universeId));
router.push("/universen");
})
}
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm text-[var(--fl-danger)]"
>
Ja, löschen
</button>
<button
type="button"
onClick={() => setConfirming(false)}
className="text-[var(--fl-faint)] hover:text-[var(--fl-text)]"
>
Abbrechen
</button>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Bild-URLs und ihre Prüfung.
*
* Bewusst **ohne** `server-only`: Posterkacheln werden auch aus
* Client-Komponenten heraus gerendert (etwa in der sortierbaren Liste eines
* Universums). Alles, was tatsächlich den Server braucht — Abruf bei TMDB und
* der Dateicache — liegt in `images.ts`.
*/
/** Zugelassene Größen — verhindert, dass über den Parameter beliebige URLs entstehen. */
export const IMAGE_SIZES = [
"w92",
"w154",
"w185",
"w300",
"w342",
"w500",
"w780",
"w1280",
"original",
] as const;
export type ImageSize = (typeof IMAGE_SIZES)[number];
const PATH_PATTERN = /^\/[A-Za-z0-9]{8,64}\.(jpg|jpeg|png|svg|webp)$/;
export function isValidImagePath(path: string): boolean {
return PATH_PATTERN.test(path);
}
export function isValidSize(size: string): size is ImageSize {
return (IMAGE_SIZES as readonly string[]).includes(size);
}
/** URL des eigenen Bild-Endpunkts — überall im UI statt der TMDB-Adresse benutzen. */
export function posterUrl(
path: string | null | undefined,
size: ImageSize = "w342",
): string | null {
if (!path || !isValidImagePath(path)) return null;
return `/api/image?size=${size}&path=${encodeURIComponent(path)}`;
}
+6 -30
View File
@@ -4,6 +4,7 @@ import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { env } from "./env"; import { env } from "./env";
import { IMAGE_BASE } from "./tmdb"; import { IMAGE_BASE } from "./tmdb";
import type { ImageSize } from "./image-url";
/** /**
* Lokaler Bild-Zwischenspeicher. * Lokaler Bild-Zwischenspeicher.
@@ -12,32 +13,13 @@ import { IMAGE_BASE } from "./tmdb";
* eigenen Server ausgeliefert. Das spart Anfragen, macht die Oberfläche auch * eigenen Server ausgeliefert. Das spart Anfragen, macht die Oberfläche auch
* ohne Internetverbindung nutzbar und verhindert, dass jeder Seitenaufruf * ohne Internetverbindung nutzbar und verhindert, dass jeder Seitenaufruf
* einen Abruf bei einem Dritten auslöst. * einen Abruf bei einem Dritten auslöst.
*
* Die URL-Bildung selbst steht in `image-url.ts` — die brauchen auch
* Client-Komponenten, und sie darf deshalb nicht hinter `server-only` liegen.
*/ */
/** Zugelassene Größen — verhindert, dass jemand über den Parameter beliebige URLs baut. */ export { IMAGE_SIZES, isValidImagePath, isValidSize, posterUrl } from "./image-url";
export const IMAGE_SIZES = [ export type { ImageSize } from "./image-url";
"w92",
"w154",
"w185",
"w300",
"w342",
"w500",
"w780",
"w1280",
"original",
] as const;
export type ImageSize = (typeof IMAGE_SIZES)[number];
const PATH_PATTERN = /^\/[A-Za-z0-9]{8,64}\.(jpg|jpeg|png|svg|webp)$/;
export function isValidImagePath(path: string): boolean {
return PATH_PATTERN.test(path);
}
export function isValidSize(size: string): size is ImageSize {
return (IMAGE_SIZES as readonly string[]).includes(size);
}
function cacheDir() { function cacheDir() {
return join(env.dataDir, "images"); return join(env.dataDir, "images");
@@ -99,9 +81,3 @@ export async function getCachedImage(size: ImageSize, path: string): Promise<Cac
fromCache: false, fromCache: false,
}; };
} }
/** URL des eigenen Bild-Endpunkts — überall im UI statt der TMDB-Adresse benutzen. */
export function posterUrl(path: string | null | undefined, size: ImageSize = "w342"): string | null {
if (!path || !isValidImagePath(path)) return null;
return `/api/image?size=${size}&path=${encodeURIComponent(path)}`;
}
+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);
}
+632
View File
@@ -0,0 +1,632 @@
import "server-only";
import { prisma } from "./db";
import { SPECIALS_SEASON } from "./catalog";
import type { OrderKind, SharePermission, TitleType } from "@/generated/prisma/client";
/**
* Universen: eigene Sammlungen aus Filmen **und** Serien.
*
* Zwei Dinge unterscheiden ein Universum von einer Liste:
*
* 1. Es hat **mehrere Reihenfolgen**. Die Erscheinungsreihenfolge wird aus den
* Daten berechnet und nie gespeichert — sie stimmt damit immer. Alles
* andere (Story-Chronologie, Einsteiger-Route) ist frei sortierbar.
* 2. Der **Fortschritt** ist der des jeweiligen Betrachters. Ein geteiltes
* Universum zeigt jeder Person ihren eigenen Stand.
*/
export class UniverseError extends Error {}
// ---------------------------------------------------------------------------
// Zugriff
// ---------------------------------------------------------------------------
export type UniverseAccess = { canRead: boolean; canWrite: boolean; isOwner: boolean };
export async function getAccess(userId: string, universeId: string): Promise<UniverseAccess> {
const universe = await prisma.universe.findFirst({
where: { id: universeId, deletedAt: null },
select: {
ownerId: true,
members: { where: { userId }, select: { permission: true } },
},
});
if (!universe) return { canRead: false, canWrite: false, isOwner: false };
const isOwner = universe.ownerId === userId;
const membership = universe.members[0];
return {
isOwner,
canRead: isOwner || Boolean(membership),
canWrite: isOwner || membership?.permission === "WRITE",
};
}
async function requireWrite(userId: string, universeId: string) {
const access = await getAccess(userId, universeId);
if (!access.canRead) throw new UniverseError("Dieses Universum gibt es nicht.");
if (!access.canWrite) throw new UniverseError("Du darfst dieses Universum nur ansehen.");
return access;
}
// ---------------------------------------------------------------------------
// Anlegen und pflegen
// ---------------------------------------------------------------------------
function slugify(value: string): string {
return value
.toLocaleLowerCase("de-DE")
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60) || "universum";
}
export async function createUniverse(userId: string, name: string, description?: string) {
const base = slugify(name);
let slug = base;
// Der Slug ist je Besitzer eindeutig — bei Dubletten durchzählen.
for (let suffix = 2; suffix < 100; suffix += 1) {
const taken = await prisma.universe.findUnique({
where: { ownerId_slug: { ownerId: userId, slug } },
select: { id: true },
});
if (!taken) break;
slug = `${base}-${suffix}`;
}
const universe = await prisma.universe.create({
data: {
ownerId: userId,
name: name.trim(),
slug,
description: description?.trim() || null,
orders: {
create: [
// Die Erscheinungsreihenfolge braucht keine Einträge — sie wird
// aus den Erscheinungsdaten berechnet.
{ name: "Erscheinungsreihenfolge", kind: "RELEASE", isDefault: true },
],
},
},
});
return universe;
}
export async function renameUniverse(
userId: string,
universeId: string,
data: { name?: string; description?: string | null },
) {
await requireWrite(userId, universeId);
return prisma.universe.update({
where: { id: universeId },
data: {
...(data.name ? { name: data.name.trim() } : {}),
...(data.description !== undefined ? { description: data.description?.trim() || null } : {}),
},
});
}
export async function trashUniverse(userId: string, universeId: string) {
const access = await getAccess(userId, universeId);
if (!access.isOwner) throw new UniverseError("Nur wer das Universum angelegt hat, darf es löschen.");
return prisma.universe.update({
where: { id: universeId },
data: { deletedAt: new Date() },
});
}
/** Nimmt einen Titel ins Universum auf und hängt ihn an alle Reihenfolgen an. */
export async function addTitle(userId: string, universeId: string, titleId: string) {
await requireWrite(userId, universeId);
const existing = await prisma.universeItem.findUnique({
where: { universeId_titleId: { universeId, titleId } },
select: { id: true },
});
if (existing) return existing;
const item = await prisma.universeItem.create({
data: { universeId, titleId },
select: { id: true },
});
await appendToCustomOrders(universeId, item.id);
return item;
}
/** Neue Einträge landen ans Ende jeder frei sortierten Reihenfolge. */
async function appendToCustomOrders(universeId: string, itemId: string) {
const orders = await prisma.universeOrder.findMany({
where: { universeId, kind: "CUSTOM" },
select: { id: true },
});
for (const order of orders) {
const last = await prisma.universeOrderItem.findFirst({
where: { orderId: order.id },
orderBy: { position: "desc" },
select: { position: true },
});
await prisma.universeOrderItem.create({
data: { orderId: order.id, itemId, position: (last?.position ?? -1) + 1 },
});
}
}
export async function removeTitle(userId: string, universeId: string, itemId: string) {
await requireWrite(userId, universeId);
await prisma.universeItem.delete({ where: { id: itemId } });
}
/**
* Übernimmt alle Filme einer TMDB-Collection, die bereits im Katalog liegen.
* Der eigentliche Abruf bei TMDB passiert vorher in der Katalogschicht.
*/
export async function addCollection(
userId: string,
universeId: string,
tmdbCollectionId: number,
): Promise<number> {
await requireWrite(userId, universeId);
const titles = await prisma.title.findMany({
where: { tmdbCollectionId },
orderBy: { releaseDate: "asc" },
select: { id: true },
});
let added = 0;
for (const title of titles) {
const existing = await prisma.universeItem.findUnique({
where: { universeId_titleId: { universeId, titleId: title.id } },
select: { id: true },
});
if (existing) continue;
const item = await prisma.universeItem.create({
data: { universeId, titleId: title.id },
select: { id: true },
});
await appendToCustomOrders(universeId, item.id);
added += 1;
}
return added;
}
// ---------------------------------------------------------------------------
// Reihenfolgen
// ---------------------------------------------------------------------------
export async function createOrder(userId: string, universeId: string, name: string) {
await requireWrite(userId, universeId);
const order = await prisma.universeOrder.create({
data: { universeId, name: name.trim(), kind: "CUSTOM" },
select: { id: true },
});
// Startbelegung: Erscheinungsreihenfolge — von dort aus umsortiert es sich
// leichter als aus einer zufälligen Anordnung.
const items = await prisma.universeItem.findMany({
where: { universeId },
orderBy: [{ title: { releaseDate: "asc" } }, { title: { title: "asc" } }],
select: { id: true },
});
await prisma.universeOrderItem.createMany({
data: items.map((item, index) => ({ orderId: order.id, itemId: item.id, position: index })),
});
return order;
}
export async function renameOrder(userId: string, orderId: string, name: string) {
const order = await prisma.universeOrder.findUniqueOrThrow({
where: { id: orderId },
select: { universeId: true, kind: true },
});
await requireWrite(userId, order.universeId);
if (order.kind === "RELEASE") {
throw new UniverseError("Die Erscheinungsreihenfolge lässt sich nicht umbenennen.");
}
return prisma.universeOrder.update({ where: { id: orderId }, data: { name: name.trim() } });
}
export async function deleteOrder(userId: string, orderId: string) {
const order = await prisma.universeOrder.findUniqueOrThrow({
where: { id: orderId },
select: { universeId: true, kind: true },
});
await requireWrite(userId, order.universeId);
if (order.kind === "RELEASE") {
throw new UniverseError("Die Erscheinungsreihenfolge gehört fest dazu.");
}
await prisma.universeOrder.delete({ where: { id: orderId } });
}
/** Schreibt eine neue Anordnung — die Liste enthält alle Einträge in Wunschfolge. */
export async function setOrderPositions(userId: string, orderId: string, itemIds: string[]) {
const order = await prisma.universeOrder.findUniqueOrThrow({
where: { id: orderId },
select: { universeId: true, kind: true },
});
await requireWrite(userId, order.universeId);
if (order.kind === "RELEASE") {
throw new UniverseError("Die Erscheinungsreihenfolge ergibt sich aus den Daten.");
}
// Erst alle auf negative Positionen schieben: die Eindeutigkeit von
// (orderId, itemId) bleibt, aber wir vermeiden Kollisionen beim Umnummerieren.
await prisma.$transaction(
itemIds.map((itemId, index) =>
prisma.universeOrderItem.updateMany({
where: { orderId, itemId },
data: { position: index },
}),
),
);
}
// ---------------------------------------------------------------------------
// Teilen
// ---------------------------------------------------------------------------
export async function shareUniverse(
userId: string,
universeId: string,
targetEmail: string,
permission: SharePermission,
) {
const access = await getAccess(userId, universeId);
if (!access.isOwner) throw new UniverseError("Nur wer das Universum angelegt hat, darf es teilen.");
const target = await prisma.user.findUnique({
where: { email: targetEmail.trim().toLowerCase() },
select: { id: true, name: true },
});
if (!target) throw new UniverseError("Zu dieser E-Mail-Adresse gibt es auf dieser Instanz kein Konto.");
if (target.id === userId) throw new UniverseError("Mit dir selbst musst du nicht teilen.");
await prisma.universeMember.upsert({
where: { universeId_userId: { universeId, userId: target.id } },
create: { universeId, userId: target.id, permission },
update: { permission },
});
return target;
}
export async function unshareUniverse(userId: string, universeId: string, memberId: string) {
const access = await getAccess(userId, universeId);
if (!access.isOwner) throw new UniverseError("Nur wer das Universum angelegt hat, darf das ändern.");
await prisma.universeMember.delete({ where: { id: memberId } });
}
// ---------------------------------------------------------------------------
// Lesen und Fortschritt
// ---------------------------------------------------------------------------
export type UniverseEntry = {
itemId: string;
titleId: string;
title: string;
type: TitleType;
posterPath: string | null;
year: number | null;
note: string | null;
/** Gesamtlaufzeit des Titels in Minuten. */
totalMinutes: number;
/** Noch offene Laufzeit für diesen Betrachter. */
remainingMinutes: number;
watchedMinutes: number;
inLibrary: boolean;
completed: boolean;
status: string | null;
watchedEpisodes: number;
totalEpisodes: number;
};
export type UniverseView = {
id: string;
name: string;
description: string | null;
isOwner: boolean;
canWrite: boolean;
ownerName: string;
orders: { id: string; name: string; kind: OrderKind; isDefault: boolean }[];
activeOrderId: string;
entries: UniverseEntry[];
progress: {
completed: number;
total: number;
percent: number;
watchedMinutes: number;
remainingMinutes: number;
totalMinutes: number;
};
/** Der erste noch nicht abgeschlossene Eintrag in der gewählten Reihenfolge. */
nextUp: UniverseEntry | null;
members: { id: string; name: string; email: string; permission: SharePermission }[];
};
export async function getUniverse(
userId: string,
universeId: string,
orderId?: string,
): Promise<UniverseView | null> {
const access = await getAccess(userId, universeId);
if (!access.canRead) return null;
const universe = await prisma.universe.findUnique({
where: { id: universeId },
include: {
owner: { select: { name: true } },
orders: { orderBy: [{ kind: "asc" }, { createdAt: "asc" }] },
members: {
include: { user: { select: { name: true, email: true } } },
orderBy: { createdAt: "asc" },
},
items: {
include: {
title: {
select: {
id: true,
title: true,
type: true,
posterPath: true,
releaseDate: true,
runtime: true,
episodeCount: true,
},
},
positions: true,
},
},
},
});
if (!universe) return null;
const activeOrder =
universe.orders.find((order) => order.id === orderId) ??
universe.orders.find((order) => order.isDefault) ??
universe.orders[0];
if (!activeOrder) return null;
const titleIds = universe.items.map((item) => item.titleId);
const [entries, seriesRuntimes, watchedByTitle, watchedEpisodes] = await Promise.all([
prisma.libraryEntry.findMany({
where: { userId, titleId: { in: titleIds }, deletedAt: null },
select: { titleId: true, status: true, currentRun: true },
}),
// Gesamtlaufzeit je Serie aus den Episoden (ohne Specials).
prisma.episode.groupBy({
by: ["titleId"],
where: { titleId: { in: titleIds }, seasonNumber: { gt: SPECIALS_SEASON } },
_sum: { runtime: true },
_count: { _all: true },
}),
prisma.watch.groupBy({
by: ["titleId"],
where: { userId, titleId: { in: titleIds } },
_sum: { minutes: true },
}),
// Gesehene reguläre Episoden im jeweils aktuellen Durchlauf.
prisma.watch.groupBy({
by: ["titleId", "run"],
where: {
userId,
titleId: { in: titleIds },
episode: { seasonNumber: { gt: SPECIALS_SEASON } },
},
_count: { _all: true },
}),
]);
const entryByTitle = new Map(entries.map((entry) => [entry.titleId, entry]));
const runtimeByTitle = new Map(
seriesRuntimes.map((row) => [row.titleId, { minutes: row._sum.runtime ?? 0, episodes: row._count._all }]),
);
const minutesByTitle = new Map(watchedByTitle.map((row) => [row.titleId, row._sum.minutes ?? 0]));
const seenByTitle = new Map<string, number>();
for (const row of watchedEpisodes) {
const entry = entryByTitle.get(row.titleId);
if (!entry || entry.currentRun !== row.run) continue;
seenByTitle.set(row.titleId, row._count._all);
}
const built: UniverseEntry[] = universe.items.map((item) => {
const title = item.title;
const isSeries = title.type === "SERIES";
const runtimeInfo = runtimeByTitle.get(title.id);
const totalEpisodes = isSeries ? (runtimeInfo?.episodes ?? title.episodeCount) : 1;
const totalMinutes = isSeries
? (runtimeInfo?.minutes ?? (title.runtime ?? 0) * title.episodeCount)
: (title.runtime ?? 0);
const entry = entryByTitle.get(title.id);
const watchedEpisodeCount = isSeries
? Math.min(seenByTitle.get(title.id) ?? 0, totalEpisodes)
: entry?.status === "COMPLETED"
? 1
: 0;
const completed =
entry?.status === "COMPLETED" ||
(totalEpisodes > 0 && watchedEpisodeCount >= totalEpisodes);
// Verbleibende Zeit: bei Serien anteilig über die offenen Episoden,
// bei Filmen alles oder nichts.
const remainingMinutes = completed
? 0
: isSeries && totalEpisodes > 0
? Math.round((totalMinutes * (totalEpisodes - watchedEpisodeCount)) / totalEpisodes)
: totalMinutes;
return {
itemId: item.id,
titleId: title.id,
title: title.title,
type: title.type,
posterPath: title.posterPath,
year: title.releaseDate?.getFullYear() ?? null,
note: item.note,
totalMinutes,
remainingMinutes,
watchedMinutes: minutesByTitle.get(title.id) ?? 0,
inLibrary: Boolean(entry),
completed,
status: entry?.status ?? null,
watchedEpisodes: watchedEpisodeCount,
totalEpisodes,
};
});
// Sortieren: Erscheinungsreihenfolge rechnerisch, sonst nach den Positionen.
const positionByItem = new Map<string, number>();
for (const item of universe.items) {
const position = item.positions.find((row) => row.orderId === activeOrder.id);
if (position) positionByItem.set(item.id, position.position);
}
const releaseYear = new Map(
universe.items.map((item) => [item.id, item.title.releaseDate?.getTime() ?? Number.MAX_SAFE_INTEGER]),
);
const sorted = [...built].sort((a, b) => {
if (activeOrder.kind === "RELEASE") {
const diff = (releaseYear.get(a.itemId) ?? 0) - (releaseYear.get(b.itemId) ?? 0);
return diff !== 0 ? diff : a.title.localeCompare(b.title, "de");
}
const positionA = positionByItem.get(a.itemId) ?? Number.MAX_SAFE_INTEGER;
const positionB = positionByItem.get(b.itemId) ?? Number.MAX_SAFE_INTEGER;
return positionA !== positionB
? positionA - positionB
: a.title.localeCompare(b.title, "de");
});
const completed = sorted.filter((entry) => entry.completed).length;
const watchedMinutes = sorted.reduce((sum, entry) => sum + entry.watchedMinutes, 0);
const remainingMinutes = sorted.reduce((sum, entry) => sum + entry.remainingMinutes, 0);
const totalMinutes = sorted.reduce((sum, entry) => sum + entry.totalMinutes, 0);
return {
id: universe.id,
name: universe.name,
description: universe.description,
isOwner: access.isOwner,
canWrite: access.canWrite,
ownerName: universe.owner.name,
orders: universe.orders.map((order) => ({
id: order.id,
name: order.name,
kind: order.kind,
isDefault: order.isDefault,
})),
activeOrderId: activeOrder.id,
entries: sorted,
progress: {
completed,
total: sorted.length,
percent: sorted.length > 0 ? Math.round((completed / sorted.length) * 100) : 0,
watchedMinutes,
remainingMinutes,
totalMinutes,
},
nextUp: sorted.find((entry) => !entry.completed) ?? null,
members: universe.members.map((member) => ({
id: member.id,
name: member.user.name,
email: member.user.email,
permission: member.permission,
})),
};
}
export type UniverseSummary = {
id: string;
name: string;
description: string | null;
isOwner: boolean;
ownerName: string;
itemCount: number;
completed: number;
percent: number;
posters: string[];
};
/** Eigene und mit einem geteilte Universen. */
export async function listUniverses(userId: string): Promise<UniverseSummary[]> {
const universes = await prisma.universe.findMany({
where: {
deletedAt: null,
OR: [{ ownerId: userId }, { members: { some: { userId } } }],
},
orderBy: { updatedAt: "desc" },
include: {
owner: { select: { name: true } },
items: {
include: {
title: { select: { id: true, posterPath: true, releaseDate: true, type: true } },
},
orderBy: { title: { releaseDate: "asc" } },
},
},
});
if (universes.length === 0) return [];
const allTitleIds = universes.flatMap((universe) => universe.items.map((item) => item.titleId));
const completedEntries = await prisma.libraryEntry.findMany({
where: { userId, titleId: { in: allTitleIds }, status: "COMPLETED", deletedAt: null },
select: { titleId: true },
});
const completedTitles = new Set(completedEntries.map((entry) => entry.titleId));
return universes.map((universe) => {
const completed = universe.items.filter((item) => completedTitles.has(item.titleId)).length;
return {
id: universe.id,
name: universe.name,
description: universe.description,
isOwner: universe.ownerId === userId,
ownerName: universe.owner.name,
itemCount: universe.items.length,
completed,
percent: universe.items.length > 0 ? Math.round((completed / universe.items.length) * 100) : 0,
posters: universe.items
.map((item) => item.title.posterPath)
.filter((path): path is string => Boolean(path))
.slice(0, 5),
};
});
}
/** TMDB-Collections, die in der Bibliothek des Nutzers vorkommen. */
export async function suggestCollections(userId: string) {
const rows = await prisma.title.findMany({
where: {
tmdbCollectionId: { not: null },
entries: { some: { userId, deletedAt: null } },
},
select: { tmdbCollectionId: true, tmdbCollectionName: true },
distinct: ["tmdbCollectionId"],
orderBy: { tmdbCollectionName: "asc" },
});
return rows
.filter((row) => row.tmdbCollectionId && row.tmdbCollectionName)
.map((row) => ({ id: row.tmdbCollectionId!, name: row.tmdbCollectionName! }));
}
+177
View File
@@ -0,0 +1,177 @@
import "./setup";
import assert from "node:assert/strict";
import test, { after, before } from "node:test";
import { installTmdbStub, SERIES_ID, MOVIE_ID } from "./fixtures/tmdb";
import { prisma } from "../src/lib/db";
import { importMovie, importSeries } from "../src/lib/catalog";
import { addToLibrary, markEpisodesWatched, markMovieWatched } from "../src/lib/library";
import {
getContinueWatching,
getMonthlyActivity,
getTotals,
getUpcomingEpisodes,
getYearReview,
} from "../src/lib/stats";
/**
* Prüft die Auswertungen. Besonderes Augenmerk auf die Monatsgruppierung:
* dort führte ein Zeitzonenversatz zwischen Datenbank und JavaScript einmal
* dazu, dass das Diagramm leer blieb, obwohl Sichtungen vorlagen.
*/
const stub = installTmdbStub();
const TEST_EMAIL = "test-stats@flimmer.invalid";
let userId: string;
let entryId: string;
let seriesTitleId: string;
async function cleanup() {
await prisma.user.deleteMany({ where: { email: TEST_EMAIL } });
await prisma.title.deleteMany({ where: { tmdbId: { gte: 999000, lt: 1000000 } } });
}
before(async () => {
await cleanup();
userId = (
await prisma.user.create({ data: { email: TEST_EMAIL, name: "Statistik" }, select: { id: true } })
).id;
seriesTitleId = (await importSeries(SERIES_ID, { region: "DE" })).id;
const movieTitleId = (await importMovie(MOVIE_ID, "DE")).id;
const entry = await addToLibrary(userId, seriesTitleId, "WATCHING");
entryId = entry.id;
const episodes = await prisma.episode.findMany({
where: { titleId: seriesTitleId, seasonNumber: { gt: 0 } },
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
select: { id: true },
});
// Drei Episoden: heute, vor einem Monat, vor zwei Monaten.
for (const [index, episode] of episodes.slice(0, 3).entries()) {
const when = new Date();
when.setMonth(when.getMonth() - index);
when.setHours(20, 30, 0, 0);
await markEpisodesWatched(userId, entry.id, [episode.id], when);
}
const movieEntry = await addToLibrary(userId, movieTitleId, "WATCHING");
await markMovieWatched(userId, movieEntry.id, new Date());
});
after(async () => {
await cleanup();
stub.restore();
await prisma.$disconnect();
});
test("Gesamtzahlen zählen Episoden und Filme getrennt", async () => {
const totals = await getTotals(userId);
assert.equal(totals.episodes, 3);
assert.equal(totals.movies, 1);
// 52 + 47 + 48 aus den Episoden, 118 aus dem Film
assert.equal(totals.minutes, 52 + 47 + 48 + 118);
assert.equal(totals.seriesCount, 1);
assert.equal(totals.movieCount, 1);
});
test("Monatsverlauf ordnet Sichtungen dem richtigen Monat zu", async () => {
const points = await getMonthlyActivity(userId, 12);
assert.equal(points.length, 12);
const total = points.reduce((sum, point) => sum + point.episodes + point.movies, 0);
assert.equal(total, 4, "keine Sichtung fällt beim Gruppieren durchs Raster");
const current = points[points.length - 1];
assert.ok(current);
assert.equal(current.episodes, 1, "die Sichtung von heute liegt im laufenden Monat");
assert.equal(current.movies, 1);
const previous = points[points.length - 2];
assert.equal(previous?.episodes, 1, "die Sichtung von vor einem Monat liegt im Vormonat");
// Lückenlos: auch Monate ohne Sichtung sind vertreten.
assert.ok(points.some((point) => point.episodes === 0 && point.movies === 0));
});
test("Jahresrückblick fasst das laufende Jahr zusammen", async () => {
const review = await getYearReview(userId, new Date().getFullYear());
assert.ok(review.minutes > 0);
assert.ok(review.episodes >= 1);
assert.equal(review.movies, 1);
assert.ok(review.busiestMonth, "der stärkste Monat wird benannt");
assert.match(
review.busiestMonth!.label,
/\d{4}$/,
"die Beschriftung endet auf die Jahreszahl, nicht auf einen rohen Zeitstempel",
);
assert.ok(review.topTitle);
});
test("Weiterschauen nennt die nächste offene Episode", async () => {
const items = await getContinueWatching(userId, 10);
const series = items.find((item) => item.titleId === seriesTitleId);
assert.ok(series, "die laufende Serie steht in der Liste");
assert.equal(series.watched, 3);
assert.equal(series.total, 5);
assert.equal(series.next?.episodeNumber, 1);
assert.equal(series.next?.seasonNumber, 2, "nach Staffel 1 kommt Staffel 2");
assert.ok(series.lastWatchedAt);
});
test("Weiterschauen lässt durchgeschaute Serien weg", async () => {
const episodes = await prisma.episode.findMany({
where: { titleId: seriesTitleId, seasonNumber: { gt: 0 } },
select: { id: true },
});
await markEpisodesWatched(userId, entryId, episodes.map((episode) => episode.id));
const items = await getContinueWatching(userId, 10);
assert.equal(
items.find((item) => item.titleId === seriesTitleId),
undefined,
"wo nichts mehr offen ist, gibt es auch nichts weiterzuschauen",
);
});
test("Kalender liefert nur Episoden im gefragten Zeitraum", async () => {
// Die Fixture hat eine Episode am 08.01.2099. Der vorige Test hat die Serie
// auf „Abgeschlossen" gebracht — sie muss trotzdem im Kalender stehen, denn
// bei einer laufenden Serie heißt „abgeschlossen" nur: auf dem Stand.
const episodes = await getUpcomingEpisodes(
userId,
new Date("2098-12-01T00:00:00.000Z"),
new Date("2099-03-01T00:00:00.000Z"),
);
assert.equal(episodes.length, 1);
assert.equal(episodes[0]?.seasonNumber, 2);
assert.equal(episodes[0]?.episodeNumber, 2);
const empty = await getUpcomingEpisodes(
userId,
new Date("2050-01-01T00:00:00.000Z"),
new Date("2050-12-31T00:00:00.000Z"),
);
assert.equal(empty.length, 0, "außerhalb des Zeitraums kommt nichts");
});
test("Abgebrochenes taucht im Kalender nicht mehr auf", async () => {
await prisma.libraryEntry.update({ where: { id: entryId }, data: { status: "DROPPED" } });
const episodes = await getUpcomingEpisodes(
userId,
new Date("2098-12-01T00:00:00.000Z"),
new Date("2099-03-01T00:00:00.000Z"),
);
assert.equal(episodes.length, 0, "wer abgebrochen hat, braucht keine Erinnerung");
await prisma.libraryEntry.update({ where: { id: entryId }, data: { status: "COMPLETED" } });
});
+282
View File
@@ -0,0 +1,282 @@
import "./setup";
import assert from "node:assert/strict";
import test, { after, before, beforeEach } from "node:test";
import { installTmdbStub, SERIES_ID, MOVIE_ID, MOVIE_2_ID, COLLECTION_ID } from "./fixtures/tmdb";
import { prisma } from "../src/lib/db";
import { importCollection, importSeries } from "../src/lib/catalog";
import { addToLibrary, markEpisodesWatched, markMovieWatched } from "../src/lib/library";
import {
addCollection,
addTitle,
createOrder,
createUniverse,
getAccess,
getUniverse,
listUniverses,
setOrderPositions,
shareUniverse,
trashUniverse,
} from "../src/lib/universes";
/**
* Prüft Universen: Reihenfolgen, Fortschritt aus Sicht des Betrachters,
* Teilen und die Rechteprüfung.
*/
const stub = installTmdbStub();
const OWNER_EMAIL = "test-uni-owner@flimmer.invalid";
const GUEST_EMAIL = "test-uni-guest@flimmer.invalid";
let ownerId: string;
let guestId: string;
let seriesTitleId: string;
let movieTitleId: string;
let movie2TitleId: string;
async function cleanup() {
await prisma.user.deleteMany({ where: { email: { in: [OWNER_EMAIL, GUEST_EMAIL] } } });
await prisma.title.deleteMany({ where: { tmdbId: { gte: 999000, lt: 1000000 } } });
}
before(async () => {
await cleanup();
ownerId = (
await prisma.user.create({ data: { email: OWNER_EMAIL, name: "Besitzerin" }, select: { id: true } })
).id;
guestId = (
await prisma.user.create({ data: { email: GUEST_EMAIL, name: "Gast" }, select: { id: true } })
).id;
seriesTitleId = (await importSeries(SERIES_ID, { region: "DE" })).id;
const { titles } = await importCollection(COLLECTION_ID, "DE");
movieTitleId = titles.find((title) => title.tmdbId === MOVIE_ID)!.id;
movie2TitleId = titles.find((title) => title.tmdbId === MOVIE_2_ID)!.id;
});
beforeEach(async () => {
await prisma.universe.deleteMany({ where: { ownerId } });
await prisma.watch.deleteMany({ where: { userId: { in: [ownerId, guestId] } } });
await prisma.libraryEntry.deleteMany({ where: { userId: { in: [ownerId, guestId] } } });
});
after(async () => {
await cleanup();
stub.restore();
await prisma.$disconnect();
});
test("ein neues Universum bringt die Erscheinungsreihenfolge mit", async () => {
const universe = await createUniverse(ownerId, "Vorführer-Universum", "Zwei Filme, eine Rolle.");
const view = await getUniverse(ownerId, universe.id);
assert.ok(view);
assert.equal(view.orders.length, 1);
assert.equal(view.orders[0]?.kind, "RELEASE");
assert.equal(view.orders[0]?.isDefault, true);
assert.equal(view.entries.length, 0);
});
test("Slugs bleiben je Besitzer eindeutig", async () => {
const first = await createUniverse(ownerId, "Sternenpfad");
const second = await createUniverse(ownerId, "Sternenpfad");
assert.equal(first.slug, "sternenpfad");
assert.equal(second.slug, "sternenpfad-2", "die Dublette wird durchgezählt");
});
test("Filme und Serien landen gemeinsam in einem Universum", async () => {
const universe = await createUniverse(ownerId, "Gemischt");
await addTitle(ownerId, universe.id, seriesTitleId);
await addTitle(ownerId, universe.id, movieTitleId);
const view = await getUniverse(ownerId, universe.id);
assert.equal(view?.entries.length, 2);
assert.deepEqual(
view?.entries.map((entry) => entry.type).sort(),
["MOVIE", "SERIES"],
"Serien und Filme stehen gleichberechtigt nebeneinander",
);
// Zweimal derselbe Titel legt keinen zweiten Eintrag an.
await addTitle(ownerId, universe.id, movieTitleId);
const again = await getUniverse(ownerId, universe.id);
assert.equal(again?.entries.length, 2);
});
test("TMDB-Collection lässt sich als Startpunkt übernehmen", async () => {
const universe = await createUniverse(ownerId, "Aus Collection");
const added = await addCollection(ownerId, universe.id, COLLECTION_ID);
assert.equal(added, 2);
const view = await getUniverse(ownerId, universe.id);
assert.deepEqual(
view?.entries.map((entry) => entry.title),
[
"Die Stille zwischen den Bildern",
"Die Stille zwischen den Bildern II",
],
"Erscheinungsreihenfolge ergibt sich aus den Daten",
);
// Erneutes Übernehmen fügt nichts doppelt hinzu.
assert.equal(await addCollection(ownerId, universe.id, COLLECTION_ID), 0);
});
test("eigene Reihenfolge lässt sich frei sortieren", async () => {
const universe = await createUniverse(ownerId, "Sortierbar");
await addCollection(ownerId, universe.id, COLLECTION_ID);
await addTitle(ownerId, universe.id, seriesTitleId);
const order = await createOrder(ownerId, universe.id, "Chronologisch");
const before = await getUniverse(ownerId, universe.id, order.id);
assert.equal(before?.entries.length, 3);
// Umdrehen
const reversed = [...before!.entries].reverse().map((entry) => entry.itemId);
await setOrderPositions(ownerId, order.id, reversed);
const after = await getUniverse(ownerId, universe.id, order.id);
assert.deepEqual(
after?.entries.map((entry) => entry.itemId),
reversed,
"die eigene Reihenfolge steht so, wie sie gesetzt wurde",
);
// Die Erscheinungsreihenfolge bleibt davon unberührt.
const release = await getUniverse(ownerId, universe.id, before!.orders.find((o) => o.kind === "RELEASE")!.id);
assert.notDeepEqual(release?.entries.map((entry) => entry.itemId), reversed);
});
test("die Erscheinungsreihenfolge lässt sich nicht umsortieren", async () => {
const universe = await createUniverse(ownerId, "Fest");
await addTitle(ownerId, universe.id, movieTitleId);
const view = await getUniverse(ownerId, universe.id);
const releaseOrder = view!.orders.find((order) => order.kind === "RELEASE")!;
await assert.rejects(
() => setOrderPositions(ownerId, releaseOrder.id, [view!.entries[0]!.itemId]),
/ergibt sich aus den Daten/,
);
});
test("Fortschritt und Restlaufzeit rechnen über Filme und Serien", async () => {
const universe = await createUniverse(ownerId, "Fortschritt");
await addTitle(ownerId, universe.id, movieTitleId);
await addTitle(ownerId, universe.id, seriesTitleId);
const fresh = await getUniverse(ownerId, universe.id);
// Film 118 min + Serie 5 Episoden (52+47+48+50+50 = 247 min)
assert.equal(fresh?.progress.total, 2);
assert.equal(fresh?.progress.completed, 0);
assert.equal(fresh?.progress.totalMinutes, 118 + 247);
assert.equal(fresh?.progress.remainingMinutes, 118 + 247);
// Erscheinungsreihenfolge: die Serie (2023) läuft vor dem Film (2024).
assert.equal(fresh?.nextUp?.title, "Nordlicht");
// Film sehen
const movieEntry = await addToLibrary(ownerId, movieTitleId);
await markMovieWatched(ownerId, movieEntry.id);
const afterMovie = await getUniverse(ownerId, universe.id);
assert.equal(afterMovie?.progress.completed, 1);
assert.equal(afterMovie?.progress.percent, 50);
assert.equal(afterMovie?.progress.watchedMinutes, 118);
assert.equal(afterMovie?.progress.remainingMinutes, 247);
assert.equal(
afterMovie?.nextUp?.type,
"SERIES",
"der Film ist durch, offen bleibt die Serie",
);
// Zwei Episoden der Serie
const seriesEntry = await addToLibrary(ownerId, seriesTitleId);
const episodes = await prisma.episode.findMany({
where: { titleId: seriesTitleId, seasonNumber: { gt: 0 } },
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
select: { id: true },
});
await markEpisodesWatched(ownerId, seriesEntry.id, [episodes[0]!.id, episodes[1]!.id]);
const afterEpisodes = await getUniverse(ownerId, universe.id);
const series = afterEpisodes!.entries.find((entry) => entry.type === "SERIES")!;
assert.equal(series.watchedEpisodes, 2);
assert.equal(series.totalEpisodes, 5);
assert.equal(series.completed, false);
assert.ok(
series.remainingMinutes < 247 && series.remainingMinutes > 0,
"die Restlaufzeit schrumpft anteilig",
);
});
test("geteilte Universen zeigen jedem den eigenen Fortschritt", async () => {
const universe = await createUniverse(ownerId, "Geteilt");
await addTitle(ownerId, universe.id, movieTitleId);
const entry = await addToLibrary(ownerId, movieTitleId);
await markMovieWatched(ownerId, entry.id);
await shareUniverse(ownerId, universe.id, GUEST_EMAIL, "READ");
const ownerView = await getUniverse(ownerId, universe.id);
const guestView = await getUniverse(guestId, universe.id);
assert.equal(ownerView?.progress.completed, 1);
assert.equal(guestView?.progress.completed, 0, "der Gast hat den Film noch nicht gesehen");
assert.equal(guestView?.canWrite, false, "Leserecht bleibt Leserecht");
assert.equal(guestView?.isOwner, false);
});
test("Schreibrecht lässt sich vergeben, Löschen bleibt beim Besitzer", async () => {
const universe = await createUniverse(ownerId, "Gemeinsam");
await shareUniverse(ownerId, universe.id, GUEST_EMAIL, "WRITE");
const access = await getAccess(guestId, universe.id);
assert.equal(access.canWrite, true);
assert.equal(access.isOwner, false);
// Mit Schreibrecht darf der Gast Titel aufnehmen …
await addTitle(guestId, universe.id, movieTitleId);
const view = await getUniverse(guestId, universe.id);
assert.equal(view?.entries.length, 1);
// … aber nicht löschen.
await assert.rejects(() => trashUniverse(guestId, universe.id), /Nur wer das Universum angelegt hat/);
});
test("Fremde sehen ein Universum gar nicht", async () => {
const universe = await createUniverse(ownerId, "Privat");
const access = await getAccess(guestId, universe.id);
assert.equal(access.canRead, false);
assert.equal(await getUniverse(guestId, universe.id), null);
await assert.rejects(() => addTitle(guestId, universe.id, movieTitleId), /gibt es nicht/);
});
test("die Übersicht listet eigene und geteilte Universen", async () => {
const own = await createUniverse(ownerId, "Meins");
await addTitle(ownerId, own.id, movieTitleId);
await addTitle(ownerId, own.id, movie2TitleId);
await shareUniverse(ownerId, own.id, GUEST_EMAIL, "READ");
const entry = await addToLibrary(guestId, movieTitleId);
await markMovieWatched(guestId, entry.id);
const guestList = await listUniverses(guestId);
assert.equal(guestList.length, 1);
assert.equal(guestList[0]?.isOwner, false);
assert.equal(guestList[0]?.ownerName, "Besitzerin");
assert.equal(guestList[0]?.itemCount, 2);
assert.equal(guestList[0]?.completed, 1, "der Gast hat einen der beiden Filme gesehen");
assert.equal(guestList[0]?.percent, 50);
});
test("gelöschte Universen verschwinden aus der Übersicht", async () => {
const universe = await createUniverse(ownerId, "Weg damit");
await trashUniverse(ownerId, universe.id);
assert.equal(await getUniverse(ownerId, universe.id), null);
const list = await listUniverses(ownerId);
assert.equal(list.find((item) => item.id === universe.id), undefined);
});