diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index 34d6c78..5e55b8d 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -237,6 +237,46 @@ Alles ist aus den Sichtungen gerechnet; Rewatches zählen mit. --- +## Errungenschaften + +![Errungenschaften](screenshots/23-errungenschaften.webp) + +Unten auf der Statistikseite sammeln sich **Abzeichen**. Sie tauchen auf, wenn +etwas erreicht ist — mehr passiert nicht. Keine Serien, die reißen, wenn du +eine Woche nichts schaust, keine Balken, die dich antreiben. + +Ein paar davon: + +- **Abgehakt** — die erste Serie vollständig durchgeschaut +- **Der lange Atem** — eine Serie mit mindestens hundert Episoden komplett +- **Treu geblieben** — über ein Jahr an demselben Titel drangeblieben +- **Durchgemacht** — zwischen drei und fünf Uhr morgens etwas abgehakt +- **Zeitreisender** — Titel aus sieben Jahrzehnten gesehen +- **Strenges Urteil** — einmal die Note 1 vergeben + +Dazu kommen Abzeichen für **ganze Filmreihen**: Wer alle Spider-Man-Filme +gesehen hat, bekommt sie als solche angerechnet. Flimmer erkennt das an der +TMDB-Kennung, die zu jedem Film mitgeliefert wird — es braucht keine gepflegte +Liste. Für bekannte Reihen gibt es einen eigenen Namen, sonst heißt es schlicht +„Reihe komplett — …". Genauso zählen **eigene Universen**, sobald alle Titel +darin abgeschlossen sind. + +> **Einmal erreicht, bleibt erreicht.** +> Nimmst du eine Sichtung zurück oder entfernst einen Titel, verlierst du kein +> Abzeichen. Es wieder einzukassieren wäre kleinlich — und ein Fehlklick soll +> nichts kaputt machen. + +Beim ersten Öffnen der Seite schaut Flimmer einmal deine ganze Historie durch. +Wenn du Flimmer schon länger nutzt, sind deshalb sofort mehrere Abzeichen da — +ohne dass du dafür eine Flut von Meldungen bekommst. Erst danach wird gemeldet, +was neu dazukommt. + +Wenn du ein öffentliches Profil hast, kannst du die Abzeichen dort zeigen oder +ausblenden — der Schalter steht in den Einstellungen direkt bei der +Profiladresse. + +--- + ## Einstellungen ![Einstellungen](screenshots/11-einstellungen.webp) diff --git a/docs/screenshots/02-uebersicht.webp b/docs/screenshots/02-uebersicht.webp index 27e5fef..1427cac 100644 Binary files a/docs/screenshots/02-uebersicht.webp and b/docs/screenshots/02-uebersicht.webp differ diff --git a/docs/screenshots/10-statistik.webp b/docs/screenshots/10-statistik.webp index 86dc3d1..b179361 100644 Binary files a/docs/screenshots/10-statistik.webp and b/docs/screenshots/10-statistik.webp differ diff --git a/docs/screenshots/11-einstellungen.webp b/docs/screenshots/11-einstellungen.webp index bcbabe5..cc707c8 100644 Binary files a/docs/screenshots/11-einstellungen.webp and b/docs/screenshots/11-einstellungen.webp differ diff --git a/docs/screenshots/22-medienserver.webp b/docs/screenshots/22-medienserver.webp index c6a4ec5..5cb9529 100644 Binary files a/docs/screenshots/22-medienserver.webp and b/docs/screenshots/22-medienserver.webp differ diff --git a/docs/screenshots/23-errungenschaften.webp b/docs/screenshots/23-errungenschaften.webp new file mode 100644 index 0000000..96af1ff Binary files /dev/null and b/docs/screenshots/23-errungenschaften.webp differ diff --git a/prisma/migrations/20260902094830_errungenschaften/migration.sql b/prisma/migrations/20260902094830_errungenschaften/migration.sql new file mode 100644 index 0000000..f4a01fa --- /dev/null +++ b/prisma/migrations/20260902094830_errungenschaften/migration.sql @@ -0,0 +1,22 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "showAchievements" BOOLEAN NOT NULL DEFAULT true; + +-- CreateTable +CREATE TABLE "Achievement" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "key" TEXT NOT NULL, + "label" TEXT, + "unlockedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Achievement_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Achievement_userId_unlockedAt_idx" ON "Achievement"("userId", "unlockedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "Achievement_userId_key_key" ON "Achievement"("userId", "key"); + +-- AddForeignKey +ALTER TABLE "Achievement" ADD CONSTRAINT "Achievement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 469b622..c76f2b3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -42,6 +42,9 @@ model User { publicProfile Boolean @default(false) profileSlug String? @unique + /// Errungenschaften auf der öffentlichen Profilseite zeigen + showAchievements Boolean @default(true) + // Benachrichtigungswünsche notifyNewEpisode Boolean @default(true) notifyRelease Boolean @default(true) @@ -55,6 +58,7 @@ model User { oidcAccounts OidcAccount[] passkeys Passkey[] mediaServers MediaServerConnection[] + achievements Achievement[] entries LibraryEntry[] watches Watch[] tags Tag[] @@ -628,3 +632,27 @@ model MediaServerItem { @@unique([connectionId, externalId]) @@index([connectionId]) } + +// --------------------------------------------------------------------------- +// Errungenschaften +// --------------------------------------------------------------------------- + +/// Ein erreichtes Abzeichen. Einmal vergeben, bleibt es — auch wenn die +/// Bedingung später nicht mehr erfuellt waere. +model Achievement { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + /// Stabiler Schluessel: "serientaeter-1000", "reihe:556", "universum:". + key String + + /// Bei dynamisch benannten Abzeichen der Name zum Zeitpunkt des Erreichens. + /// Eingefroren: ein umbenanntes Universum soll das Abzeichen nicht aendern. + label String? + + unlockedAt DateTime @default(now()) + + @@unique([userId, key]) + @@index([userId, unlockedAt]) +} diff --git a/scripts/screenshots.mjs b/scripts/screenshots.mjs index 86a6253..cea6feb 100644 --- a/scripts/screenshots.mjs +++ b/scripts/screenshots.mjs @@ -43,6 +43,11 @@ async function ausschnitt(page, name, ueberschrift) { } await page.waitForTimeout(400); + // Die Kopfleiste klebt oben und legt sich sonst über die Überschrift des + // Ausschnitts. Fürs Bild kurz aufheben. + await page.addStyleTag({ content: "header { position: static !important; }" }).catch(() => {}); + await page.waitForTimeout(200); + const bereich = page.locator("section", { hasText: ueberschrift }).first(); await bereich.screenshot({ path: `${OUT}/${name}.png` }); console.log(" ", name); @@ -199,6 +204,16 @@ async function shot(page, name, url, opts = {}) { await jellyfin.close(); } +// --- Errungenschaften --- +{ + const { ctx, page } = await session(1400, 1200, "dark"); + await login(page); + await page.goto(`${BASE}/statistik`, { waitUntil: "networkidle" }); + await page.waitForTimeout(900); + await ausschnitt(page, "23-errungenschaften", "Errungenschaften"); + await ctx.close(); +} + // --- Hell --- { const { ctx, page } = await session(1400, 900, "light"); diff --git a/src/app/actions/library.ts b/src/app/actions/library.ts index f72a36a..5b8b834 100644 --- a/src/app/actions/library.ts +++ b/src/app/actions/library.ts @@ -24,6 +24,7 @@ import { type WatchResult, } from "@/lib/library"; import { prisma } from "@/lib/db"; +import { achievementName, evaluateAchievements } from "@/lib/achievements"; /** * Server-Aktionen rund um die Bibliothek. @@ -38,11 +39,14 @@ export type ActionResult = { message?: string; /** Neu angelegte Sichtungen — Grundlage für „Rückgängig". */ watchIds?: string[]; + /** Gerade erreichte Errungenschaften, fertig benannt. */ + achievements?: string[]; }; async function run( fn: (userId: string) => Promise<{ message?: string; watchIds?: string[] } | void>, revalidate: string[] = [], + { pruefeErrungenschaften = false }: { pruefeErrungenschaften?: boolean } = {}, ): Promise { const user = await getSessionUser(); if (!user) return { ok: false, message: "Bitte melde dich an." }; @@ -50,7 +54,25 @@ async function run( try { const result = (await fn(user.id)) ?? {}; for (const path of revalidate) revalidatePath(path, "layout"); - return { ok: true, message: result.message, watchIds: result.watchIds }; + + // Nur nach Sichtungsaktionen nachsehen — eine Tag-Änderung kann kein + // Abzeichen auslösen, und die Auswertung liest die ganze Sichtungshistorie. + let achievements: string[] | undefined; + if (pruefeErrungenschaften) { + try { + const ergebnis = await evaluateAchievements(user.id); + if (ergebnis.neu.length > 0) { + achievements = ergebnis.neu.map((eintrag) => + achievementName(eintrag.key, eintrag.label), + ); + } + } catch (error) { + // Ein Abzeichen ist die Kür. Es darf das Abhaken nie scheitern lassen. + console.error("Errungenschaften konnten nicht ausgewertet werden:", error); + } + } + + return { ok: true, message: result.message, watchIds: result.watchIds, achievements }; } catch (error) { if (error instanceof LibraryError) return { ok: false, message: error.message }; console.error("Bibliotheksaktion fehlgeschlagen:", error); @@ -75,7 +97,7 @@ export async function addTitleAction(titleId: string, status: string = "WATCHLIS message: parsedStatus === "WATCHLIST" ? "Auf die Watchlist gesetzt" : "Zur Bibliothek hinzugefügt", }; - }, PATHS); + }, PATHS, { pruefeErrungenschaften: true }); } export async function trashEntryAction(entryId: string) { @@ -130,7 +152,7 @@ export async function startRewatchAction(entryId: string) { return run(async (userId) => { const entry = await startRewatch(userId, cuid.parse(entryId)); return { message: `Durchlauf ${entry.currentRun + 1} gestartet` }; - }, PATHS); + }, PATHS, { pruefeErrungenschaften: true }); } // --------------------------------------------------------------------------- @@ -148,7 +170,7 @@ export async function toggleEpisodeAction(entryId: string, episodeId: string, wa } await unmarkEpisodes(userId, cuid.parse(entryId), [cuid.parse(episodeId)]); return { message: "Markierung entfernt" }; - }, PATHS); + }, PATHS, { pruefeErrungenschaften: true }); } export async function toggleSeasonAction(entryId: string, seasonId: string, watched: boolean) { @@ -158,36 +180,37 @@ export async function toggleSeasonAction(entryId: string, seasonId: string, watc } await unmarkSeason(userId, cuid.parse(entryId), cuid.parse(seasonId)); return { message: "Staffel zurückgesetzt" }; - }, PATHS); + }, PATHS, { pruefeErrungenschaften: true }); } export async function markUpToAction(entryId: string, episodeId: string) { return run( async (userId) => toResult(await markUpTo(userId, cuid.parse(entryId), cuid.parse(episodeId))), PATHS, + { pruefeErrungenschaften: true }, ); } export async function watchNextAction(entryId: string) { - return run(async (userId) => toResult(await watchNextEpisode(userId, cuid.parse(entryId))), PATHS); + return run(async (userId) => toResult(await watchNextEpisode(userId, cuid.parse(entryId))), PATHS, { pruefeErrungenschaften: true }); } export async function markMovieAction(entryId: string) { - return run(async (userId) => toResult(await markMovieWatched(userId, cuid.parse(entryId))), PATHS); + return run(async (userId) => toResult(await markMovieWatched(userId, cuid.parse(entryId))), PATHS, { pruefeErrungenschaften: true }); } export async function unmarkMovieAction(entryId: string) { return run(async (userId) => { await unmarkMovie(userId, cuid.parse(entryId)); return { message: "Markierung entfernt" }; - }, PATHS); + }, PATHS, { pruefeErrungenschaften: true }); } export async function undoAction(entryId: string, watchIds: string[]) { return run(async (userId) => { await undoWatches(userId, cuid.parse(entryId), z.array(cuid).max(500).parse(watchIds)); return { message: "Rückgängig gemacht" }; - }, PATHS); + }, PATHS, { pruefeErrungenschaften: true }); } // --------------------------------------------------------------------------- @@ -234,7 +257,7 @@ export async function setTagsAction(entryId: string, tagNames: string[]) { ]); return { message: "Tags gespeichert" }; - }, PATHS); + }, PATHS, { pruefeErrungenschaften: true }); } // --------------------------------------------------------------------------- diff --git a/src/app/actions/settings.ts b/src/app/actions/settings.ts index 38acd8d..194a49a 100644 --- a/src/app/actions/settings.ts +++ b/src/app/actions/settings.ts @@ -64,6 +64,7 @@ const profileSchema = z.object({ name: z.string().trim().min(1).max(80), region: z.string().trim().length(2).toUpperCase(), publicProfile: z.boolean(), + showAchievements: z.boolean(), profileSlug: z .string() .trim() diff --git a/src/app/einstellungen/page.tsx b/src/app/einstellungen/page.tsx index 0a0560c..d869006 100644 --- a/src/app/einstellungen/page.tsx +++ b/src/app/einstellungen/page.tsx @@ -24,6 +24,7 @@ export default async function SettingsPage() { region: true, publicProfile: true, profileSlug: true, + showAchievements: true, notifyNewEpisode: true, notifyRelease: true, notifyAvailability: true, diff --git a/src/app/statistik/page.tsx b/src/app/statistik/page.tsx index 761c58b..1011cd0 100644 --- a/src/app/statistik/page.tsx +++ b/src/app/statistik/page.tsx @@ -16,6 +16,8 @@ import { formatMinutes } from "@/lib/labels"; import { AppShell } from "@/components/AppShell"; import { Poster } from "@/components/Poster"; import { DistributionBars, MonthlyChart } from "@/components/stats/Charts"; +import { AchievementBadge } from "@/components/AchievementBadge"; +import { GRUPPEN, ensureAchievementsEvaluated, listAchievements } from "@/lib/achievements"; export const metadata: Metadata = { title: "Statistik" }; @@ -42,6 +44,13 @@ export default async function StatsPage({ getYearReview(user.id, year), ]); + // Beim allerersten Aufruf einmal auswerten: sonst bliebe die Liste leer, + // bis nachts der Hintergrundlauf kommt — und wer sie öffnet, will sehen, + // was er hat, nicht auf morgen vertröstet werden. Der Lauf ist stumm und + // passiert genau einmal je Konto. + await ensureAchievementsEvaluated(user.id); + const abzeichen = await listAchievements(user.id); + if (totals.minutes === 0 && totals.episodes === 0 && totals.movies === 0) { return ( @@ -205,6 +214,48 @@ export default async function StatsPage({ emptyText="Noch nichts bewertet." /> + +
+

+ Errungenschaften +

+

+ {abzeichen.filter((eintrag) => eintrag.unlockedAt).length} von {abzeichen.length}{" "} + erreicht. Was hier steht, bleibt stehen — auch wenn du eine Sichtung zurücknimmst. +

+ +
+ {GRUPPEN.map((gruppe) => { + const eintraege = abzeichen.filter((eintrag) => eintrag.gruppe === gruppe.key); + if (eintraege.length === 0) return null; + + // Erreichte zuerst — man will sehen, was man hat, nicht was fehlt. + const sortiert = [...eintraege].sort((a, b) => { + if (Boolean(a.unlockedAt) !== Boolean(b.unlockedAt)) return a.unlockedAt ? -1 : 1; + return a.name.localeCompare(b.name, "de-DE"); + }); + + return ( +
+

+ {gruppe.label} +

+
    + {sortiert.map((eintrag) => ( + + ))} +
+
+ ); + })} +
+
); } diff --git a/src/app/u/[slug]/page.tsx b/src/app/u/[slug]/page.tsx index ec2ee94..6d59122 100644 --- a/src/app/u/[slug]/page.tsx +++ b/src/app/u/[slug]/page.tsx @@ -2,6 +2,8 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import type { Metadata } from "next"; import { prisma } from "@/lib/db"; +import { listUnlockedAchievements } from "@/lib/achievements"; +import { AchievementSymbol } from "@/components/AchievementBadge"; import { getSettings } from "@/lib/settings"; import { getMostWatched, getTopRated, getTotals } from "@/lib/stats"; import { formatMinutes, formatRelative } from "@/lib/labels"; @@ -35,10 +37,12 @@ export default async function PublicProfilePage({ params }: { params: Params }) const user = await prisma.user.findFirst({ where: { profileSlug: slug, publicProfile: true, isActive: true }, - select: { id: true, name: true, createdAt: true }, + select: { id: true, name: true, createdAt: true, showAchievements: true }, }); if (!user) notFound(); + const abzeichen = user.showAchievements ? await listUnlockedAchievements(user.id) : []; + const [totals, mostWatched, topRated, recent] = await Promise.all([ getTotals(user.id), getMostWatched(user.id, 6), @@ -89,6 +93,24 @@ export default async function PublicProfilePage({ params }: { params: Params }) + {abzeichen.length > 0 && ( +
+

Errungenschaften

+
    + {abzeichen.map((eintrag) => ( +
  • + + {eintrag.name} +
  • + ))} +
+
+ )} + {recent.length > 0 && (

Zuletzt gesehen

diff --git a/src/components/AchievementBadge.tsx b/src/components/AchievementBadge.tsx new file mode 100644 index 0000000..1c2405b --- /dev/null +++ b/src/components/AchievementBadge.tsx @@ -0,0 +1,203 @@ +import { clsx } from "clsx"; +import type { SymbolName } from "@/lib/achievements"; + +/** + * Abzeichen mit Symbol. + * + * Die Symbole sind eigene SVG statt Emoji: Emoji sehen auf jedem System + * anders aus, lassen sich nicht einfärben und passen nicht zur Formsprache + * des Logos. Alle liegen auf demselben 24er-Raster, zeichnen nur mit + * `currentColor` und tragen keine Farbe für sich — die kommt vom Zustand. + * + * Erreicht und offen unterscheiden sich **nicht nur durch Farbe**: das offene + * Abzeichen ist blass *und* trägt den Hinweis „noch offen" im Text, das + * erreichte nennt sein Datum. Farbe allein wäre für Screenreader und bei + * Farbsehschwäche nichts wert. + */ + +const PFADE: Record = { + spross: ( + <> + + + + + ), + haken: , + schluessel: ( + <> + + + + ), + haus: , + klappe: ( + <> + + + + ), + planet: ( + <> + + + + ), + kompass: ( + <> + + + + ), + bildschirm: ( + <> + + + + ), + filmrolle: ( + <> + + + + + + ), + sonne: ( + <> + + + + ), + kalender: ( + <> + + + + ), + laeufer: ( + <> + + + + ), + berg: , + herz: , + kreis: ( + <> + + + + ), + maske: ( + <> + + + + + ), + sanduhr: ( + <> + + + + ), + stern: , + salz: ( + <> + + + + ), + scheibe: ( + <> + + + + ), + besen: ( + <> + + + + + ), + mond: , + spinne: ( + <> + + + + ), +}; + +export function AchievementSymbol({ + symbol, + className, +}: { + symbol: SymbolName; + className?: string; +}) { + return ( + + {PFADE[symbol]} + + ); +} + +export function AchievementBadge({ + name, + beschreibung, + symbol, + unlockedAt, +}: { + name: string; + beschreibung: string; + symbol: SymbolName; + unlockedAt: string | null; +}) { + const erreicht = unlockedAt !== null; + + return ( +
  • + + + + +
    +

    {name}

    +

    {beschreibung}

    +

    + {erreicht ? `erreicht am ${formatiere(unlockedAt)}` : "noch offen"} +

    +
    +
  • + ); +} + +function formatiere(wert: string): string { + return new Date(wert).toLocaleDateString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); +} diff --git a/src/components/Toasts.tsx b/src/components/Toasts.tsx index d5079b4..84283f7 100644 --- a/src/components/Toasts.tsx +++ b/src/components/Toasts.tsx @@ -16,7 +16,7 @@ import type { ActionResult } from "@/app/actions/library"; type Toast = { id: number; message: string; - tone: "info" | "error"; + tone: "info" | "error" | "erfolg"; undo?: () => Promise | void; }; @@ -58,6 +58,12 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { show(result.message, { undo: undo && ids.length > 0 ? () => void undo(ids) : undefined, }); + + // Errungenschaften bekommen eine eigene Einblendung — sie sollen nicht + // in der Meldung über das Abhaken untergehen. + for (const name of result.achievements ?? []) { + show(`Errungenschaft: ${name}`, { tone: "erfolg" }); + } }, [show], ); @@ -96,8 +102,15 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void } borderColor: toast.tone === "error" ? "color-mix(in oklab, var(--fl-danger) 50%, transparent)" - : "var(--fl-border)", - color: toast.tone === "error" ? "var(--fl-danger)" : "var(--fl-text)", + : toast.tone === "erfolg" + ? "color-mix(in oklab, var(--fl-success) 50%, transparent)" + : "var(--fl-border)", + color: + toast.tone === "error" + ? "var(--fl-danger)" + : toast.tone === "erfolg" + ? "var(--fl-success)" + : "var(--fl-text)", }} > {toast.message} diff --git a/src/components/settings/Forms.tsx b/src/components/settings/Forms.tsx index 83d0e74..7b7a65d 100644 --- a/src/components/settings/Forms.tsx +++ b/src/components/settings/Forms.tsx @@ -100,6 +100,7 @@ export function ProfileForm({ email: string; region: string; publicProfile: boolean; + showAchievements: boolean; profileSlug: string | null; notifyNewEpisode: boolean; notifyRelease: boolean; @@ -121,6 +122,7 @@ export function ProfileForm({ name: state.name, region: state.region, publicProfile: state.publicProfile, + showAchievements: state.showAchievements, profileSlug: state.profileSlug || null, notifyNewEpisode: state.notifyNewEpisode, notifyRelease: state.notifyRelease, @@ -190,14 +192,22 @@ export function ProfileForm({ hint="Eine schlichte Seite mit deinen Zahlen und zuletzt gesehenen Titeln — ohne Notizen." /> {state.publicProfile && ( - setState({ ...state, profileSlug: event.target.value })} - placeholder="dein-name" - hint="Erreichbar unter /u/" - /> + <> + setState({ ...state, profileSlug: event.target.value })} + placeholder="dein-name" + hint="Erreichbar unter /u/" + /> + setState({ ...state, showAchievements: value })} + label="Errungenschaften zeigen" + hint="Die erreichten Abzeichen erscheinen auf der Profilseite." + /> + )} )} diff --git a/src/lib/achievement-names.ts b/src/lib/achievement-names.ts new file mode 100644 index 0000000..4268132 --- /dev/null +++ b/src/lib/achievement-names.ts @@ -0,0 +1,69 @@ +/** + * Eigennamen für bekannte Filmreihen. + * + * Die Grundlage ist generisch: „Reihe komplett — Spider-Man" funktioniert für + * jede Reihe, die TMDB kennt, und braucht keine Pflege. Diese Datei setzt + * obendrauf: wo ein Eintrag passt, bekommt das Abzeichen einen Namen mit + * Augenzwinkern. Fehlt er, greift der generische — nichts hängt davon ab. + * + * Die Schlüssel sind **TMDB-Collection-Kennungen**, nachzuschlagen unter + * `themoviedb.org/collection/`. + * + * ## Warum jeder Eintrag ein `erwartet` hat + * + * Eine falsch abgeschriebene Kennung wäre schlimmer als gar kein Eintrag: Wer + * die Ocean's-Reihe durchhat, bekäme „Spider Bro". Deshalb trägt jeder Eintrag + * ein Stück des Namens, den TMDB für diese Reihe führt. Passt es nicht zum + * tatsächlichen Namen, wird der Eintrag ignoriert und der generische Name + * greift. Ein Tippfehler bleibt damit folgenlos. + * + * Erweitern ist ausdrücklich erwünscht — eine Zeile hier, sonst nichts. + */ + +import type { SymbolName } from "./achievements"; + +export type KuratierteReihe = { + name: string; + symbol: SymbolName; + /** Muss im TMDB-Namen der Reihe vorkommen, sonst gilt der Eintrag nicht. */ + erwartet: string; +}; + +const REIHEN: Record = { + 556: { name: "Spider Bro", symbol: "spinne", erwartet: "Spider-Man" }, + 531241: { name: "Spider Bro", symbol: "spinne", erwartet: "Spider-Man" }, + 119: { name: "Ringträger", symbol: "kreis", erwartet: "Herr der Ringe" }, + 121938: { name: "Auf unerwarteter Reise", symbol: "berg", erwartet: "Hobbit" }, + 10: { name: "Möge die Macht", symbol: "stern", erwartet: "Star Wars" }, + 264: { name: "Wo wir hinfahren", symbol: "sanduhr", erwartet: "Zurück in die Zukunft" }, + 2344: { name: "Aufgewacht", symbol: "kreis", erwartet: "Matrix" }, + 84: { name: "Gehört ins Museum", symbol: "kompass", erwartet: "Indiana Jones" }, + 8091: { name: "Im All hört dich niemand", symbol: "planet", erwartet: "Alien" }, + 328: { name: "Leben findet einen Weg", symbol: "spross", erwartet: "Jurassic Park" }, + 295: { name: "Freibeuter", symbol: "kompass", erwartet: "Fluch der Karibik" }, + 1241: { name: "Ganze sieben Jahre", symbol: "schluessel", erwartet: "Harry Potter" }, + 528: { name: "Ich komme wieder", symbol: "kreis", erwartet: "Terminator" }, + 230: { name: "Ein Angebot", symbol: "maske", erwartet: "Pate" }, + 10194: { name: "Bis zur Unendlichkeit", symbol: "planet", erwartet: "Toy Story" }, + 2150: { name: "Aus dem Sumpf", symbol: "spross", erwartet: "Shrek" }, +}; + +/** + * Der kuratierte Eintrag — aber nur, wenn er zum tatsächlichen Namen passt. + * + * `tmdbName` ist der Name, den TMDB für diese Reihe liefert. Fehlt er, lässt + * sich nichts prüfen; dann gilt der Eintrag im Zweifel nicht. + */ +export function kuratierteReihe( + tmdbCollectionId: number, + tmdbName: string | null | undefined, +): KuratierteReihe | undefined { + const eintrag = REIHEN[tmdbCollectionId]; + if (!eintrag || !tmdbName) return undefined; + + const passt = tmdbName.toLocaleLowerCase("de-DE").includes(eintrag.erwartet.toLocaleLowerCase("de-DE")); + return passt ? eintrag : undefined; +} + +/** Nur für Tests und Übersichten. */ +export const ALLE_KURATIERTEN_REIHEN = REIHEN; diff --git a/src/lib/achievements.ts b/src/lib/achievements.ts new file mode 100644 index 0000000..e1ec843 --- /dev/null +++ b/src/lib/achievements.ts @@ -0,0 +1,668 @@ +import "server-only"; + +import { prisma } from "./db"; +import { kuratierteReihe } from "./achievement-names"; + +/** + * Errungenschaften. + * + * Grundhaltung: **still belohnen, nicht antreiben.** Keine Streaks, die einen + * bestrafen, wenn eine Woche nichts läuft. Keine Fortschrittsbalken, die + * drängeln. Ein Abzeichen taucht auf, wenn etwas erreicht ist — und bleibt. + * + * Zwei Entscheidungen, die das Verhalten prägen: + * + * - **Einmal erreicht, bleibt erreicht.** Wer eine Sichtung zurücknimmt, + * verliert kein Abzeichen. Es wieder einzukassieren wäre kleinlich, und die + * Datenlage ändert sich schon durch einen Fehlklick. + * - **Alle Regeln arbeiten auf *einem* Datensatz**, der einmal geholt wird. + * Sonst stellt jede Regel ihre eigene Datenbankabfrage, und aus zwanzig + * Abzeichen werden zwanzig Abfragen bei jedem Abhaken. + */ + +export type AchievementKey = string; + +/** Ein Abzeichen, wie es die Oberfläche braucht. */ +export type AchievementView = { + key: string; + name: string; + beschreibung: string; + symbol: SymbolName; + gruppe: Gruppe; + unlockedAt: Date | null; +}; + +export type Gruppe = "anfaenge" | "reihen" | "ausdauer" | "breite" | "ordnung"; + +export const GRUPPEN: { key: Gruppe; label: string }[] = [ + { key: "anfaenge", label: "Anfänge" }, + { key: "reihen", label: "Reihen und Universen" }, + { key: "ausdauer", label: "Ausdauer" }, + { key: "breite", label: "Breite" }, + { key: "ordnung", label: "Ordnung" }, +]; + +export type SymbolName = + | "spross" + | "haken" + | "schluessel" + | "haus" + | "klappe" + | "planet" + | "kompass" + | "bildschirm" + | "filmrolle" + | "sonne" + | "kalender" + | "laeufer" + | "berg" + | "herz" + | "kreis" + | "maske" + | "sanduhr" + | "stern" + | "salz" + | "scheibe" + | "besen" + | "mond" + | "spinne"; + +// --------------------------------------------------------------------------- +// Der Datensatz, auf dem alle Regeln arbeiten +// --------------------------------------------------------------------------- + +type Datensatz = { + /** Zeitzone des Nutzers — „nachts um vier" ist seine Uhrzeit, nicht die des Servers. */ + zeitzone: string; + eintraege: number; + minutenGesamt: number; + episodenGesehen: number; + filmeGesehen: number; + bewertungen: number; + niedrigsteBewertung: number | null; + besitzt: number; + watchlist: number; + serienAbgeschlossen: { titleId: string; episoden: number }[]; + hoechsterDurchlauf: number; + genres: Set; + jahrzehnte: Set; + /** Größter Abstand zwischen erster und letzter Sichtung derselben Serie, in Tagen. */ + laengsteTreue: number; + /** Meiste Minuten an einem Kalendertag (Zeitzone des Nutzers). */ + minutenBesterTag: number; + /** Gab es eine Sichtung zwischen 3 und 5 Uhr? */ + nachtsGesehen: boolean; + hatPasskey: boolean; + hatMedienserverLauf: boolean; + /** Vollständig gesehene TMDB-Reihen: Kennung → Name der Reihe. */ + reihen: Map; + /** Vollständig abgeschlossene Universen: Kennung → Name. */ + universen: Map; + /** Universen, die über eine eigene Reihenfolge abgeschlossen wurden. */ + eigeneReihenfolge: boolean; +}; + +async function sammle(userId: string): Promise { + // Zeitzone: Flimmer speichert sie nicht je Nutzer, also gilt die der Instanz + // (TZ in der .env). An einer Stelle, damit ein späterer Wechsel auf eine + // Zeitzone je Konto nur hier ansetzen muss. + const zeitzone = process.env.TZ || "Europe/Berlin"; + + const [eintraege, watches, serien, universen] = await Promise.all([ + prisma.libraryEntry.findMany({ + where: { userId, deletedAt: null }, + select: { + id: true, + titleId: true, + status: true, + rating: true, + owned: true, + currentRun: true, + title: { select: { type: true, genres: true, releaseDate: true, tmdbCollectionId: true } }, + }, + }), + prisma.watch.findMany({ + where: { userId }, + select: { titleId: true, episodeId: true, watchedAt: true, minutes: true }, + orderBy: { watchedAt: "asc" }, + }), + prisma.title.findMany({ + where: { entries: { some: { userId, deletedAt: null } }, type: "SERIES" }, + select: { id: true, episodeCount: true, title: true, tmdbCollectionId: true }, + }), + prisma.universe.findMany({ + where: { ownerId: userId, deletedAt: null }, + select: { + id: true, + name: true, + items: { select: { titleId: true } }, + orders: { select: { kind: true, isDefault: true } }, + }, + }), + ]); + + const [passkeys, medienserver] = await Promise.all([ + prisma.passkey.count({ where: { userId } }), + prisma.mediaServerConnection.count({ where: { userId, lastSyncAt: { not: null } } }), + ]); + + // --- Aus den Sichtungen ableiten --- + const minutenGesamt = watches.reduce((summe, watch) => summe + watch.minutes, 0); + const episodenGesehen = watches.filter((watch) => watch.episodeId !== null).length; + const filmeGesehen = watches.filter((watch) => watch.episodeId === null).length; + + const proTag = new Map(); + const spanneProTitel = new Map(); + let nachtsGesehen = false; + + for (const watch of watches) { + const tag = tagIn(watch.watchedAt, zeitzone); + proTag.set(tag, (proTag.get(tag) ?? 0) + watch.minutes); + + const stunde = stundeIn(watch.watchedAt, zeitzone); + if (stunde >= 3 && stunde < 5) nachtsGesehen = true; + + const spanne = spanneProTitel.get(watch.titleId); + if (spanne) spanne.letzte = watch.watchedAt; + else spanneProTitel.set(watch.titleId, { erste: watch.watchedAt, letzte: watch.watchedAt }); + } + + const minutenBesterTag = Math.max(0, ...proTag.values()); + const laengsteTreue = Math.max( + 0, + ...[...spanneProTitel.values()].map( + (spanne) => (spanne.letzte.getTime() - spanne.erste.getTime()) / 86_400_000, + ), + ); + + // --- Aus den Einträgen ableiten --- + const genres = new Set(); + const jahrzehnte = new Set(); + const gesehenTitel = new Set(watches.map((watch) => watch.titleId)); + + let bewertungen = 0; + let niedrigsteBewertung: number | null = null; + let besitzt = 0; + let watchlist = 0; + let hoechsterDurchlauf = 0; + + for (const eintrag of eintraege) { + if (eintrag.rating != null) { + bewertungen += 1; + niedrigsteBewertung = + niedrigsteBewertung == null ? eintrag.rating : Math.min(niedrigsteBewertung, eintrag.rating); + } + if (eintrag.owned) besitzt += 1; + if (eintrag.status === "WATCHLIST") watchlist += 1; + hoechsterDurchlauf = Math.max(hoechsterDurchlauf, eintrag.currentRun); + + // Genres und Jahrzehnte zählen nur, was tatsächlich gesehen wurde. + if (gesehenTitel.has(eintrag.titleId)) { + for (const genre of eintrag.title.genres) genres.add(genre); + if (eintrag.title.releaseDate) { + jahrzehnte.add(Math.floor(eintrag.title.releaseDate.getFullYear() / 10) * 10); + } + } + } + + // --- Abgeschlossene Serien --- + const episodenProSerie = new Map(); + for (const watch of watches) { + if (watch.episodeId === null) continue; + episodenProSerie.set(watch.titleId, (episodenProSerie.get(watch.titleId) ?? 0) + 1); + } + const serienAbgeschlossen = serien + .filter( + (serie) => + serie.episodeCount > 0 && (episodenProSerie.get(serie.id) ?? 0) >= serie.episodeCount, + ) + .map((serie) => ({ titleId: serie.id, episoden: serie.episodeCount })); + + // --- Vollständig gesehene TMDB-Reihen --- + // Eine Reihe zählt, wenn alle Filme der Reihe, die im Katalog stehen, + // gesehen sind — und es mindestens zwei sind. Ein einzelner Film ist keine + // Reihe. + const reihen = new Map(); + const reihenIds = [ + ...new Set( + eintraege + .map((eintrag) => eintrag.title.tmdbCollectionId) + .filter((id): id is number => id != null), + ), + ]; + if (reihenIds.length > 0) { + const reihenTitel = await prisma.title.findMany({ + where: { tmdbCollectionId: { in: reihenIds } }, + select: { id: true, tmdbCollectionId: true, tmdbCollectionName: true }, + }); + const proReihe = new Map(); + for (const titel of reihenTitel) { + const liste = proReihe.get(titel.tmdbCollectionId!) ?? []; + liste.push({ id: titel.id, name: titel.tmdbCollectionName }); + proReihe.set(titel.tmdbCollectionId!, liste); + } + for (const [id, titel] of proReihe) { + if (titel.length < 2) continue; + if (!titel.every((eintrag) => gesehenTitel.has(eintrag.id))) continue; + reihen.set(id, titel.find((eintrag) => eintrag.name)?.name ?? "Reihe"); + } + } + + // --- Abgeschlossene Universen --- + const abgeschlossene = new Map(); + let eigeneReihenfolge = false; + for (const universum of universen) { + if (universum.items.length === 0) continue; + if (!universum.items.every((item) => gesehenTitel.has(item.titleId))) continue; + abgeschlossene.set(universum.id, universum.name); + // „Chronologe": abgeschlossen, und die führende Reihenfolge ist eine + // selbst gebaute — nicht die berechnete Erscheinungsreihenfolge. + if (universum.orders.some((order) => order.kind === "CUSTOM" && order.isDefault)) { + eigeneReihenfolge = true; + } + } + + return { + zeitzone, + eintraege: eintraege.length, + minutenGesamt, + episodenGesehen, + filmeGesehen, + bewertungen, + niedrigsteBewertung, + besitzt, + watchlist, + serienAbgeschlossen, + hoechsterDurchlauf, + genres, + jahrzehnte, + laengsteTreue, + minutenBesterTag, + nachtsGesehen, + hatPasskey: passkeys > 0, + hatMedienserverLauf: medienserver > 0, + reihen, + universen: abgeschlossene, + eigeneReihenfolge, + }; +} + +/** + * Kalendertag und Stunde in der Zeitzone des Nutzers. + * + * Wichtig genug für eigene Helfer: beim Monatsdiagramm hat ein + * Zeitzonenversatz schon einmal für einen leeren Chart gesorgt. „Acht Stunden + * an einem Tag" und „nachts um vier" wären in der Serverzeitzone schlicht + * falsch. + */ +function tagIn(zeitpunkt: Date, zeitzone: string): string { + return new Intl.DateTimeFormat("sv-SE", { + timeZone: zeitzone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(zeitpunkt); +} + +function stundeIn(zeitpunkt: Date, zeitzone: string): number { + // Über die Bestandteile statt über die formatierte Zeichenkette: auf Deutsch + // liefert Intl für eine einzelne Stunde „03 Uhr", und daraus wird beim + // Umwandeln in eine Zahl NaN. Ein Test hat genau das aufgedeckt. + const teile = new Intl.DateTimeFormat("de-DE", { + timeZone: zeitzone, + hour: "2-digit", + hour12: false, + }).formatToParts(zeitpunkt); + + const stunde = teile.find((teil) => teil.type === "hour")?.value ?? ""; + const zahl = Number.parseInt(stunde, 10); + return Number.isFinite(zahl) ? zahl : -1; +} + +// --------------------------------------------------------------------------- +// Der Katalog +// --------------------------------------------------------------------------- + +type Regel = { + key: string; + name: string; + beschreibung: string; + symbol: SymbolName; + gruppe: Gruppe; + erfuellt: (daten: Datensatz) => boolean; +}; + +const KATALOG: Regel[] = [ + // --- Anfänge --- + { + key: "erstkontakt", + name: "Erstkontakt", + beschreibung: "Den ersten Titel in die Bibliothek aufgenommen", + symbol: "spross", + gruppe: "anfaenge", + erfuellt: (d) => d.eintraege >= 1, + }, + { + key: "abgehakt", + name: "Abgehakt", + beschreibung: "Die erste Serie vollständig durchgeschaut", + symbol: "haken", + gruppe: "anfaenge", + erfuellt: (d) => d.serienAbgeschlossen.length >= 1, + }, + { + key: "schluesselmeister", + name: "Schlüsselmeister", + beschreibung: "Einen Passkey angelegt", + symbol: "schluessel", + gruppe: "anfaenge", + erfuellt: (d) => d.hatPasskey, + }, + { + key: "heimkehrer", + name: "Heimkehrer", + beschreibung: "Den eigenen Medienserver angebunden und abgeglichen", + symbol: "haus", + gruppe: "anfaenge", + erfuellt: (d) => d.hatMedienserverLauf, + }, + + // --- Reihen und Universen (dynamische kommen weiter unten dazu) --- + { + key: "chronologe", + name: "Chronologe", + beschreibung: "Ein Universum in einer selbst gebauten Reihenfolge durchlaufen", + symbol: "kompass", + gruppe: "reihen", + erfuellt: (d) => d.eigeneReihenfolge, + }, + + // --- Ausdauer --- + { + key: "serientaeter-1000", + name: "Serientäter", + beschreibung: "1000 Episoden gesehen", + symbol: "bildschirm", + gruppe: "ausdauer", + erfuellt: (d) => d.episodenGesehen >= 1000, + }, + { + key: "vielseher-100", + name: "Vielseher", + beschreibung: "100 Filme gesehen", + symbol: "filmrolle", + gruppe: "ausdauer", + erfuellt: (d) => d.filmeGesehen >= 100, + }, + { + key: "ein-ganzer-tag", + name: "Ein ganzer Tag", + beschreibung: "24 Stunden Schauzeit insgesamt", + symbol: "sonne", + gruppe: "ausdauer", + erfuellt: (d) => d.minutenGesamt >= 24 * 60, + }, + { + key: "eine-ganze-woche", + name: "Eine ganze Woche", + beschreibung: "168 Stunden Schauzeit insgesamt", + symbol: "kalender", + gruppe: "ausdauer", + erfuellt: (d) => d.minutenGesamt >= 7 * 24 * 60, + }, + { + key: "marathon", + name: "Marathon", + beschreibung: "Acht Stunden an einem Tag", + symbol: "laeufer", + gruppe: "ausdauer", + erfuellt: (d) => d.minutenBesterTag >= 8 * 60, + }, + { + key: "langer-atem", + name: "Der lange Atem", + beschreibung: "Eine Serie mit mindestens 100 Episoden komplett gesehen", + symbol: "berg", + gruppe: "ausdauer", + erfuellt: (d) => d.serienAbgeschlossen.some((serie) => serie.episoden >= 100), + }, + { + key: "treu-geblieben", + name: "Treu geblieben", + beschreibung: "Über ein Jahr an demselben Titel drangeblieben", + symbol: "herz", + gruppe: "ausdauer", + erfuellt: (d) => d.laengsteTreue >= 365, + }, + { + key: "wiederholungstaeter", + name: "Wiederholungstäter", + beschreibung: "Einen Titel im dritten Durchlauf", + symbol: "kreis", + gruppe: "ausdauer", + erfuellt: (d) => d.hoechsterDurchlauf >= 2, + }, + + // --- Breite --- + { + key: "feinschmecker", + name: "Feinschmecker", + beschreibung: "Titel aus zehn verschiedenen Genres gesehen", + symbol: "maske", + gruppe: "breite", + erfuellt: (d) => d.genres.size >= 10, + }, + { + key: "zeitreisender", + name: "Zeitreisender", + beschreibung: "Titel aus sieben Jahrzehnten gesehen", + symbol: "sanduhr", + gruppe: "breite", + erfuellt: (d) => d.jahrzehnte.size >= 7, + }, + { + key: "kritiker-50", + name: "Kritiker", + beschreibung: "50 Bewertungen abgegeben", + symbol: "stern", + gruppe: "breite", + erfuellt: (d) => d.bewertungen >= 50, + }, + { + key: "strenges-urteil", + name: "Strenges Urteil", + beschreibung: "Einmal die Note 1 vergeben", + symbol: "salz", + gruppe: "breite", + erfuellt: (d) => d.niedrigsteBewertung === 1, + }, + + // --- Ordnung --- + { + key: "sammler-25", + name: "Sammler", + beschreibung: "25 Titel in der eigenen Sammlung", + symbol: "scheibe", + gruppe: "ordnung", + erfuellt: (d) => d.besitzt >= 25, + }, + { + key: "aufgeraeumt", + name: "Aufgeräumt", + beschreibung: "Die Watchlist vollständig abgearbeitet — bei mindestens 20 Titeln", + symbol: "besen", + gruppe: "ordnung", + // Nur sinnvoll, wenn überhaupt etwas da war: die Bibliothek muss die 20 + // hergeben, die Watchlist aber leer sein. + erfuellt: (d) => d.watchlist === 0 && d.eintraege >= 20, + }, + { + key: "durchgemacht", + name: "Durchgemacht", + beschreibung: "Zwischen drei und fünf Uhr morgens etwas abgehakt", + symbol: "mond", + gruppe: "ordnung", + erfuellt: (d) => d.nachtsGesehen, + }, +]; + +/** Alle Abzeichen, die dieser Datensatz hergibt — feste und dynamische. */ +function erreichte(daten: Datensatz): { key: string; label?: string }[] { + const treffer: { key: string; label?: string }[] = KATALOG.filter((regel) => + regel.erfuellt(daten), + ).map((regel) => ({ key: regel.key })); + + // Reihen: je vollständig gesehener TMDB-Reihe ein eigenes Abzeichen. Der + // Name wird eingefroren, damit er später nicht wandert. + // Eingefroren wird der **TMDB-Name** der Reihe, nicht der Anzeigename. Der + // kuratierte Name wird erst beim Anzeigen aufgesetzt — so lässt er sich + // später ändern, ohne gespeicherte Abzeichen anzufassen. + for (const [id, name] of daten.reihen) { + treffer.push({ key: `reihe:${id}`, label: name }); + } + for (const [id, name] of daten.universen) { + treffer.push({ key: `universum:${id}`, label: name }); + } + + return treffer; +} + +// --------------------------------------------------------------------------- +// Auswerten +// --------------------------------------------------------------------------- + +export type Ergebnis = { + /** Neu vergebene Abzeichen — leer, wenn sich nichts geändert hat. */ + neu: { key: string; label: string | null }[]; + /** Beim allerersten Lauf eines Kontos wird nichts gemeldet. */ + stumm: boolean; +}; + +/** + * Wertet aus und vergibt, was fehlt. + * + * Der **erste Lauf eines Kontos ist stumm**: bestehende Bibliotheken bringen + * Jahre an Daten mit, und zwei Dutzend Benachrichtigungen auf einmal wären + * keine Freude, sondern Lärm. + */ +export async function evaluateAchievements(userId: string): Promise { + const [daten, vorhanden] = await Promise.all([ + sammle(userId), + prisma.achievement.findMany({ where: { userId }, select: { key: true } }), + ]); + + const bekannt = new Set(vorhanden.map((eintrag) => eintrag.key)); + const erster = vorhanden.length === 0; + + const fehlend = erreichte(daten).filter((eintrag) => !bekannt.has(eintrag.key)); + if (fehlend.length === 0) return { neu: [], stumm: erster }; + + await prisma.achievement.createMany({ + data: fehlend.map((eintrag) => ({ userId, key: eintrag.key, label: eintrag.label ?? null })), + skipDuplicates: true, + }); + + return { + neu: erster ? [] : fehlend.map((eintrag) => ({ key: eintrag.key, label: eintrag.label ?? null })), + stumm: erster, + }; +} + +/** + * Wertet aus, falls für dieses Konto noch nie ausgewertet wurde. + * + * Gedacht für den ersten Aufruf der Übersicht: ohne diesen Anstoß bliebe sie + * bei einem Bestandskonto leer, bis nachts der Hintergrundlauf kommt. Ein + * Konto durchläuft das genau einmal — danach kostet es eine Zählabfrage. + */ +export async function ensureAchievementsEvaluated(userId: string): Promise { + const vorhanden = await prisma.achievement.count({ where: { userId } }); + if (vorhanden > 0) return; + await evaluateAchievements(userId); +} + +/** Für den täglichen Lauf: alle Konten durchgehen. */ +export async function evaluateAllAchievements(): Promise<{ konten: number; vergeben: number }> { + const konten = await prisma.user.findMany({ where: { isActive: true }, select: { id: true } }); + + let vergeben = 0; + for (const konto of konten) { + try { + const ergebnis = await evaluateAchievements(konto.id); + vergeben += ergebnis.neu.length; + } catch (error) { + console.error("Errungenschaften konnten nicht ausgewertet werden:", error); + } + } + + return { konten: konten.length, vergeben }; +} + +// --------------------------------------------------------------------------- +// Anzeigen +// --------------------------------------------------------------------------- + +/** + * Alles für die Übersicht: erreichte Abzeichen mit Datum, dazu die offenen + * mit ihrer Bedingung. Die offenen zu zeigen ist Absicht — sie sind die + * Landkarte, nicht die Peitsche. + */ +export async function listAchievements(userId: string): Promise { + const vergeben = await prisma.achievement.findMany({ + where: { userId }, + select: { key: true, label: true, unlockedAt: true }, + orderBy: { unlockedAt: "desc" }, + }); + const nachKey = new Map(vergeben.map((eintrag) => [eintrag.key, eintrag])); + + const feste: AchievementView[] = KATALOG.map((regel) => ({ + key: regel.key, + name: regel.name, + beschreibung: regel.beschreibung, + symbol: regel.symbol, + gruppe: regel.gruppe, + unlockedAt: nachKey.get(regel.key)?.unlockedAt ?? null, + })); + + // Dynamische gibt es nur, wenn sie erreicht wurden — eine Liste aller + // denkbaren Filmreihen wäre sinnlos lang. + const dynamisch: AchievementView[] = vergeben + .filter((eintrag) => eintrag.key.includes(":")) + .map((eintrag) => { + const istReihe = eintrag.key.startsWith("reihe:"); + const id = Number(eintrag.key.slice("reihe:".length)); + const kuratiert = istReihe ? kuratierteReihe(id, eintrag.label) : undefined; + + return { + key: eintrag.key, + name: kuratiert?.name ?? eintrag.label ?? (istReihe ? "Reihe komplett" : "Universum vollendet"), + beschreibung: istReihe + ? `Alle Filme der Reihe „${eintrag.label ?? "…"}" gesehen` + : `Alle Titel des Universums „${eintrag.label ?? "…"}" abgeschlossen`, + symbol: (kuratiert?.symbol ?? (istReihe ? "klappe" : "planet")) as SymbolName, + gruppe: "reihen" as Gruppe, + unlockedAt: eintrag.unlockedAt, + }; + }); + + return [...feste, ...dynamisch]; +} + +/** + * Anzeigename eines Abzeichens aus Schlüssel und eingefrorenem Namen. + * Wird für die Einblendung direkt nach dem Erreichen gebraucht, wo noch keine + * vollständige Liste geholt wurde. + */ +export function achievementName(key: string, label: string | null): string { + if (key.startsWith("reihe:")) { + const id = Number(key.slice("reihe:".length)); + return kuratierteReihe(id, label)?.name ?? `Reihe komplett — ${label ?? "…"}`; + } + if (key.startsWith("universum:")) return `Universum vollendet — ${label ?? "…"}`; + return KATALOG.find((regel) => regel.key === key)?.name ?? key; +} + +/** Nur die erreichten — für das öffentliche Profil. */ +export async function listUnlockedAchievements(userId: string): Promise { + return (await listAchievements(userId)).filter((abzeichen) => abzeichen.unlockedAt !== null); +} diff --git a/src/lib/sync.ts b/src/lib/sync.ts index ce4a31f..e3f8336 100644 --- a/src/lib/sync.ts +++ b/src/lib/sync.ts @@ -4,6 +4,7 @@ import { env } from "./env"; import { importSeries, importMovie, SPECIALS_SEASON } from "./catalog"; import { notifyUser } from "./notify"; import { syncAllConnections } from "./mediaserver"; +import { evaluateAllAchievements } from "./achievements"; import { episodeCode } from "./labels"; /** @@ -29,6 +30,7 @@ export type SyncStats = { sessionsPurged: number; mediaServersSynced: number; mediaServerWatches: number; + achievementsUnlocked: number; errors: string[]; }; @@ -237,6 +239,7 @@ export async function runSync({ limit = 60 }: { limit?: number } = {}): Promise< sessionsPurged: 0, mediaServersSynced: 0, mediaServerWatches: 0, + achievementsUnlocked: 0, errors: [], }; @@ -303,6 +306,18 @@ export async function runSync({ limit = 60 }: { limit?: number } = {}): Promise< ); } + // Errungenschaften: einmal täglich vollständig. Die Regeln, die nicht an + // einer Sichtung hängen (Zeitspannen, abgeschlossene Reihen), finden hier + // ihren Weg — inline waeren sie zu teuer. + try { + const abzeichen = await evaluateAllAchievements(); + stats.achievementsUnlocked = abzeichen.vergeben; + } catch (error) { + stats.errors.push( + `Errungenschaften: ${error instanceof Error ? error.message : "unbekannter Fehler"}`, + ); + } + const purged = await purgeExpired(); stats.entriesPurged = purged.entries; stats.sessionsPurged = purged.sessions; diff --git a/tests/achievements.test.ts b/tests/achievements.test.ts new file mode 100644 index 0000000..ae15f46 --- /dev/null +++ b/tests/achievements.test.ts @@ -0,0 +1,254 @@ +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 } from "./fixtures/tmdb"; +import { prisma } from "../src/lib/db"; +import { importMovie, importSeries } from "../src/lib/catalog"; +import { addToLibrary, markMovieWatched, markEpisodesWatched } from "../src/lib/library"; +import { + achievementName, + evaluateAchievements, + listAchievements, + listUnlockedAchievements, +} from "../src/lib/achievements"; +import { kuratierteReihe } from "../src/lib/achievement-names"; + +/** + * Prüft die Errungenschaften: Vergabe, Entdopplung, die Stummschaltung beim + * ersten Lauf und die Absicherung gegen falsch abgeschriebene Reihen. + */ + +const tmdb = installTmdbStub(); +const TEST_EMAIL = "test-achievements@flimmer.invalid"; + +let userId: string; +let seriesTitleId: string; +let movieTitleId: string; + +async function aufraeumen() { + await prisma.user.deleteMany({ where: { email: TEST_EMAIL } }); + await prisma.title.deleteMany({ where: { tmdbId: { gte: 999000, lt: 1000000 } } }); +} + +before(async () => { + await aufraeumen(); + userId = ( + await prisma.user.create({ + data: { email: TEST_EMAIL, name: "Sammlerin" }, + select: { id: true }, + }) + ).id; + + seriesTitleId = (await importSeries(SERIES_ID, { region: "DE" })).id; + movieTitleId = (await importMovie(MOVIE_ID, "DE")).id; + await importMovie(MOVIE_2_ID, "DE"); +}); + +beforeEach(async () => { + await prisma.achievement.deleteMany({ where: { userId } }); + await prisma.watch.deleteMany({ where: { userId } }); + await prisma.libraryEntry.deleteMany({ where: { userId } }); +}); + +after(async () => { + await aufraeumen(); + tmdb.restore(); + await prisma.$disconnect(); +}); + +// --------------------------------------------------------------------------- + +test("der erste Lauf eines Kontos ist stumm", async () => { + // Bestandskonten bringen Jahre an Daten mit. Zwei Dutzend Meldungen auf + // einmal waeren Laerm, kein Erfolgserlebnis. + await addToLibrary(userId, movieTitleId, "WATCHLIST"); + + const erster = await evaluateAchievements(userId); + assert.equal(erster.stumm, true, "der erste Lauf meldet nichts"); + assert.deepEqual(erster.neu, [], "auch wenn er Abzeichen vergibt"); + + // Vergeben wurde trotzdem. + const vergeben = await prisma.achievement.count({ where: { userId } }); + assert.ok(vergeben > 0, "Erstkontakt ist erreicht"); +}); + +test("ab dem zweiten Lauf wird gemeldet", async () => { + await addToLibrary(userId, movieTitleId, "WATCHLIST"); + await evaluateAchievements(userId); // stummer Erstlauf + + const eintrag = await prisma.libraryEntry.findFirstOrThrow({ + where: { userId, titleId: movieTitleId }, + select: { id: true }, + }); + await markMovieWatched(userId, eintrag.id); + await prisma.libraryEntry.update({ where: { id: eintrag.id }, data: { currentRun: 2 } }); + + const zweiter = await evaluateAchievements(userId); + assert.equal(zweiter.stumm, false); + assert.ok( + zweiter.neu.some((abzeichen) => abzeichen.key === "wiederholungstaeter"), + "der dritte Durchlauf wird gemeldet", + ); +}); + +test("ein zweiter Lauf vergibt nichts doppelt", async () => { + await addToLibrary(userId, movieTitleId, "WATCHLIST"); + await evaluateAchievements(userId); + const nachErstem = await prisma.achievement.count({ where: { userId } }); + + const zweiter = await evaluateAchievements(userId); + assert.deepEqual(zweiter.neu, []); + assert.equal(await prisma.achievement.count({ where: { userId } }), nachErstem); +}); + +test("einmal erreicht bleibt erreicht — auch wenn die Bedingung wegfällt", async () => { + const eintrag = await addToLibrary(userId, movieTitleId, "WATCHLIST"); + await evaluateAchievements(userId); + + assert.ok( + (await prisma.achievement.findFirst({ where: { userId, key: "erstkontakt" } })) != null, + ); + + // Titel wieder entfernen: die Bedingung ist nicht mehr erfüllt. + await prisma.libraryEntry.delete({ where: { id: eintrag.id } }); + await evaluateAchievements(userId); + + assert.ok( + (await prisma.achievement.findFirst({ where: { userId, key: "erstkontakt" } })) != null, + "das Abzeichen bleibt stehen", + ); +}); + +test("Durchgemacht rechnet in der Zeitzone der Instanz", async () => { + const eintrag = await addToLibrary(userId, seriesTitleId, "WATCHING"); + const episoden = await prisma.episode.findMany({ + where: { titleId: seriesTitleId }, + take: 1, + select: { id: true }, + }); + await markEpisodesWatched(userId, eintrag.id, [episoden[0]!.id]); + + // 03:30 Uhr in Europe/Berlin ist im Sommer 01:30 UTC. Wer in UTC rechnet, + // verpasst das Abzeichen — genau der Fehler, der schon einmal das + // Monatsdiagramm leergefegt hat. + await prisma.watch.updateMany({ + where: { userId }, + data: { watchedAt: new Date("2026-07-15T01:30:00.000Z") }, + }); + + await evaluateAchievements(userId); + const nachts = await prisma.achievement.findFirst({ where: { userId, key: "durchgemacht" } }); + assert.ok(nachts, "03:30 Ortszeit zählt als Nacht"); +}); + +test("Tagsueber gesehen loest Durchgemacht nicht aus", async () => { + const eintrag = await addToLibrary(userId, seriesTitleId, "WATCHING"); + const episoden = await prisma.episode.findMany({ + where: { titleId: seriesTitleId }, + take: 1, + select: { id: true }, + }); + await markEpisodesWatched(userId, eintrag.id, [episoden[0]!.id]); + await prisma.watch.updateMany({ + where: { userId }, + data: { watchedAt: new Date("2026-07-15T12:00:00.000Z") }, + }); + + await evaluateAchievements(userId); + assert.equal(await prisma.achievement.count({ where: { userId, key: "durchgemacht" } }), 0); +}); + +test("die Übersicht zeigt Erreichtes und Offenes", async () => { + await addToLibrary(userId, movieTitleId, "WATCHLIST"); + await evaluateAchievements(userId); + + const alle = await listAchievements(userId); + assert.ok(alle.length > 15, "der Katalog ist vollständig sichtbar"); + + const erreicht = alle.filter((abzeichen) => abzeichen.unlockedAt !== null); + const offen = alle.filter((abzeichen) => abzeichen.unlockedAt === null); + assert.ok(erreicht.length >= 1); + assert.ok(offen.length >= 1, "auch das noch nicht Erreichte steht da"); + + assert.deepEqual( + await listUnlockedAchievements(userId), + erreicht, + "das öffentliche Profil zeigt nur Erreichtes", + ); + + // Jedes Abzeichen trägt Name und Beschreibung — Farbe allein reicht nicht. + assert.ok(alle.every((abzeichen) => abzeichen.name && abzeichen.beschreibung)); +}); + +test("ein kuratierter Name gilt nur, wenn er zur Reihe passt", async () => { + // Richtige Kennung mit passendem Namen: greift. + assert.equal(kuratierteReihe(556, "Spider-Man Filmreihe")?.name, "Spider Bro"); + + // Dieselbe Kennung, aber TMDB führt etwas ganz anderes darunter — dann darf + // der Eintrag nicht greifen. Genau so bleibt eine falsch abgeschriebene + // Kennung folgenlos. + assert.equal(kuratierteReihe(556, "Ocean's Filmreihe"), undefined); + + // Unbekannte Kennung: nichts. + assert.equal(kuratierteReihe(999999, "Irgendwas"), undefined); + + // Ohne Namen lässt sich nichts prüfen — im Zweifel generisch. + assert.equal(kuratierteReihe(556, null), undefined); +}); + +test("Anzeigenamen entstehen aus Schlüssel und eingefrorenem Namen", () => { + assert.equal(achievementName("marathon", null), "Marathon"); + assert.equal(achievementName("reihe:556", "Spider-Man Filmreihe"), "Spider Bro"); + assert.equal( + achievementName("reihe:12345", "Nordlicht Filmreihe"), + "Reihe komplett — Nordlicht Filmreihe", + "ohne kuratierten Eintrag der generische Name", + ); + assert.equal( + achievementName("universum:abc", "Sternenpfad"), + "Universum vollendet — Sternenpfad", + ); +}); + +test("ein abgeschlossenes Universum wird zum Abzeichen", async () => { + const eintrag = await addToLibrary(userId, movieTitleId, "WATCHLIST"); + await markMovieWatched(userId, eintrag.id); + + const universum = await prisma.universe.create({ + data: { + ownerId: userId, + name: "Testuniversum", + slug: `test-achievements-${Date.now()}`, + items: { create: [{ titleId: movieTitleId }] }, + orders: { create: [{ name: "Erscheinungsreihenfolge", kind: "RELEASE", isDefault: true }] }, + }, + select: { id: true }, + }); + + try { + await evaluateAchievements(userId); + + const abzeichen = await prisma.achievement.findFirst({ + where: { userId, key: `universum:${universum.id}` }, + select: { label: true }, + }); + assert.ok(abzeichen, "das vollendete Universum zählt"); + assert.equal(abzeichen.label, "Testuniversum", "der Name wird eingefroren"); + + // Umbenennen ändert das Abzeichen nicht mehr. + await prisma.universe.update({ + where: { id: universum.id }, + data: { name: "Anders benannt" }, + }); + await evaluateAchievements(userId); + const danach = await prisma.achievement.findFirstOrThrow({ + where: { userId, key: `universum:${universum.id}` }, + select: { label: true }, + }); + assert.equal(danach.label, "Testuniversum"); + } finally { + await prisma.universe.delete({ where: { id: universum.id } }); + } +});