Feat #15: Errungenschaften — Abzeichen für erreichte Meilensteine #17

Merged
florian.wilde merged 1 commits from feat/15-errungenschaften into main 2026-09-02 11:27:12 +00:00
21 changed files with 1457 additions and 22 deletions
Showing only changes of commit b560ee9c51 - Show all commits
+40
View File
@@ -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
![Einstellungen](screenshots/11-einstellungen.webp) ![Einstellungen](screenshots/11-einstellungen.webp)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

@@ -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;
+28
View File
@@ -42,6 +42,9 @@ model User {
publicProfile Boolean @default(false) publicProfile Boolean @default(false)
profileSlug String? @unique profileSlug String? @unique
/// Errungenschaften auf der öffentlichen Profilseite zeigen
showAchievements Boolean @default(true)
// Benachrichtigungswünsche // Benachrichtigungswünsche
notifyNewEpisode Boolean @default(true) notifyNewEpisode Boolean @default(true)
notifyRelease Boolean @default(true) notifyRelease Boolean @default(true)
@@ -55,6 +58,7 @@ model User {
oidcAccounts OidcAccount[] oidcAccounts OidcAccount[]
passkeys Passkey[] passkeys Passkey[]
mediaServers MediaServerConnection[] mediaServers MediaServerConnection[]
achievements Achievement[]
entries LibraryEntry[] entries LibraryEntry[]
watches Watch[] watches Watch[]
tags Tag[] tags Tag[]
@@ -628,3 +632,27 @@ model MediaServerItem {
@@unique([connectionId, externalId]) @@unique([connectionId, externalId])
@@index([connectionId]) @@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:<id>".
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])
}
+15
View File
@@ -43,6 +43,11 @@ async function ausschnitt(page, name, ueberschrift) {
} }
await page.waitForTimeout(400); 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(); const bereich = page.locator("section", { hasText: ueberschrift }).first();
await bereich.screenshot({ path: `${OUT}/${name}.png` }); await bereich.screenshot({ path: `${OUT}/${name}.png` });
console.log(" ", name); console.log(" ", name);
@@ -199,6 +204,16 @@ async function shot(page, name, url, opts = {}) {
await jellyfin.close(); 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 --- // --- Hell ---
{ {
const { ctx, page } = await session(1400, 900, "light"); const { ctx, page } = await session(1400, 900, "light");
+33 -10
View File
@@ -24,6 +24,7 @@ import {
type WatchResult, type WatchResult,
} from "@/lib/library"; } from "@/lib/library";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { achievementName, evaluateAchievements } from "@/lib/achievements";
/** /**
* Server-Aktionen rund um die Bibliothek. * Server-Aktionen rund um die Bibliothek.
@@ -38,11 +39,14 @@ export type ActionResult = {
message?: string; message?: string;
/** Neu angelegte Sichtungen — Grundlage für „Rückgängig". */ /** Neu angelegte Sichtungen — Grundlage für „Rückgängig". */
watchIds?: string[]; watchIds?: string[];
/** Gerade erreichte Errungenschaften, fertig benannt. */
achievements?: string[];
}; };
async function run( async function run(
fn: (userId: string) => Promise<{ message?: string; watchIds?: string[] } | void>, fn: (userId: string) => Promise<{ message?: string; watchIds?: string[] } | void>,
revalidate: string[] = [], revalidate: string[] = [],
{ pruefeErrungenschaften = false }: { pruefeErrungenschaften?: boolean } = {},
): Promise<ActionResult> { ): Promise<ActionResult> {
const user = await getSessionUser(); const user = await getSessionUser();
if (!user) return { ok: false, message: "Bitte melde dich an." }; if (!user) return { ok: false, message: "Bitte melde dich an." };
@@ -50,7 +54,25 @@ async function run(
try { try {
const result = (await fn(user.id)) ?? {}; const result = (await fn(user.id)) ?? {};
for (const path of revalidate) revalidatePath(path, "layout"); 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) { } catch (error) {
if (error instanceof LibraryError) return { ok: false, message: error.message }; if (error instanceof LibraryError) return { ok: false, message: error.message };
console.error("Bibliotheksaktion fehlgeschlagen:", error); console.error("Bibliotheksaktion fehlgeschlagen:", error);
@@ -75,7 +97,7 @@ export async function addTitleAction(titleId: string, status: string = "WATCHLIS
message: message:
parsedStatus === "WATCHLIST" ? "Auf die Watchlist gesetzt" : "Zur Bibliothek hinzugefügt", parsedStatus === "WATCHLIST" ? "Auf die Watchlist gesetzt" : "Zur Bibliothek hinzugefügt",
}; };
}, PATHS); }, PATHS, { pruefeErrungenschaften: true });
} }
export async function trashEntryAction(entryId: string) { export async function trashEntryAction(entryId: string) {
@@ -130,7 +152,7 @@ export async function startRewatchAction(entryId: string) {
return run(async (userId) => { return run(async (userId) => {
const entry = await startRewatch(userId, cuid.parse(entryId)); const entry = await startRewatch(userId, cuid.parse(entryId));
return { message: `Durchlauf ${entry.currentRun + 1} gestartet` }; 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)]); await unmarkEpisodes(userId, cuid.parse(entryId), [cuid.parse(episodeId)]);
return { message: "Markierung entfernt" }; return { message: "Markierung entfernt" };
}, PATHS); }, PATHS, { pruefeErrungenschaften: true });
} }
export async function toggleSeasonAction(entryId: string, seasonId: string, watched: boolean) { 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)); await unmarkSeason(userId, cuid.parse(entryId), cuid.parse(seasonId));
return { message: "Staffel zurückgesetzt" }; return { message: "Staffel zurückgesetzt" };
}, PATHS); }, PATHS, { pruefeErrungenschaften: true });
} }
export async function markUpToAction(entryId: string, episodeId: string) { export async function markUpToAction(entryId: string, episodeId: string) {
return run( return run(
async (userId) => toResult(await markUpTo(userId, cuid.parse(entryId), cuid.parse(episodeId))), async (userId) => toResult(await markUpTo(userId, cuid.parse(entryId), cuid.parse(episodeId))),
PATHS, PATHS,
{ pruefeErrungenschaften: true },
); );
} }
export async function watchNextAction(entryId: string) { 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) { 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) { export async function unmarkMovieAction(entryId: string) {
return run(async (userId) => { return run(async (userId) => {
await unmarkMovie(userId, cuid.parse(entryId)); await unmarkMovie(userId, cuid.parse(entryId));
return { message: "Markierung entfernt" }; return { message: "Markierung entfernt" };
}, PATHS); }, PATHS, { pruefeErrungenschaften: true });
} }
export async function undoAction(entryId: string, watchIds: string[]) { export async function undoAction(entryId: string, watchIds: string[]) {
return run(async (userId) => { return run(async (userId) => {
await undoWatches(userId, cuid.parse(entryId), z.array(cuid).max(500).parse(watchIds)); await undoWatches(userId, cuid.parse(entryId), z.array(cuid).max(500).parse(watchIds));
return { message: "Rückgängig gemacht" }; 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" }; return { message: "Tags gespeichert" };
}, PATHS); }, PATHS, { pruefeErrungenschaften: true });
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+1
View File
@@ -64,6 +64,7 @@ const profileSchema = z.object({
name: z.string().trim().min(1).max(80), name: z.string().trim().min(1).max(80),
region: z.string().trim().length(2).toUpperCase(), region: z.string().trim().length(2).toUpperCase(),
publicProfile: z.boolean(), publicProfile: z.boolean(),
showAchievements: z.boolean(),
profileSlug: z profileSlug: z
.string() .string()
.trim() .trim()
+1
View File
@@ -24,6 +24,7 @@ export default async function SettingsPage() {
region: true, region: true,
publicProfile: true, publicProfile: true,
profileSlug: true, profileSlug: true,
showAchievements: true,
notifyNewEpisode: true, notifyNewEpisode: true,
notifyRelease: true, notifyRelease: true,
notifyAvailability: true, notifyAvailability: true,
+51
View File
@@ -16,6 +16,8 @@ import { formatMinutes } from "@/lib/labels";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { Poster } from "@/components/Poster"; import { Poster } from "@/components/Poster";
import { DistributionBars, MonthlyChart } from "@/components/stats/Charts"; 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" }; export const metadata: Metadata = { title: "Statistik" };
@@ -42,6 +44,13 @@ export default async function StatsPage({
getYearReview(user.id, year), 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) { if (totals.minutes === 0 && totals.episodes === 0 && totals.movies === 0) {
return ( return (
<AppShell user={user} instanceName={settings.instanceName}> <AppShell user={user} instanceName={settings.instanceName}>
@@ -205,6 +214,48 @@ export default async function StatsPage({
emptyText="Noch nichts bewertet." emptyText="Noch nichts bewertet."
/> />
</div> </div>
<section className="fl-card mt-6 p-5" aria-labelledby="errungenschaften">
<h2 id="errungenschaften" className="text-lg font-semibold">
Errungenschaften
</h2>
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
{abzeichen.filter((eintrag) => eintrag.unlockedAt).length} von {abzeichen.length}{" "}
erreicht. Was hier steht, bleibt stehen — auch wenn du eine Sichtung zurücknimmst.
</p>
<div className="space-y-5">
{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 (
<div key={gruppe.key}>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
{gruppe.label}
</h3>
<ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{sortiert.map((eintrag) => (
<AchievementBadge
key={eintrag.key}
name={eintrag.name}
beschreibung={eintrag.beschreibung}
symbol={eintrag.symbol}
unlockedAt={eintrag.unlockedAt?.toISOString() ?? null}
/>
))}
</ul>
</div>
);
})}
</div>
</section>
</AppShell> </AppShell>
); );
} }
+23 -1
View File
@@ -2,6 +2,8 @@ import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { listUnlockedAchievements } from "@/lib/achievements";
import { AchievementSymbol } from "@/components/AchievementBadge";
import { getSettings } from "@/lib/settings"; import { getSettings } from "@/lib/settings";
import { getMostWatched, getTopRated, getTotals } from "@/lib/stats"; import { getMostWatched, getTopRated, getTotals } from "@/lib/stats";
import { formatMinutes, formatRelative } from "@/lib/labels"; import { formatMinutes, formatRelative } from "@/lib/labels";
@@ -35,10 +37,12 @@ export default async function PublicProfilePage({ params }: { params: Params })
const user = await prisma.user.findFirst({ const user = await prisma.user.findFirst({
where: { profileSlug: slug, publicProfile: true, isActive: true }, 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(); if (!user) notFound();
const abzeichen = user.showAchievements ? await listUnlockedAchievements(user.id) : [];
const [totals, mostWatched, topRated, recent] = await Promise.all([ const [totals, mostWatched, topRated, recent] = await Promise.all([
getTotals(user.id), getTotals(user.id),
getMostWatched(user.id, 6), getMostWatched(user.id, 6),
@@ -89,6 +93,24 @@ export default async function PublicProfilePage({ params }: { params: Params })
</div> </div>
</header> </header>
{abzeichen.length > 0 && (
<section className="mt-6">
<h2 className="mb-3 text-lg font-semibold">Errungenschaften</h2>
<ul className="flex flex-wrap gap-2">
{abzeichen.map((eintrag) => (
<li
key={eintrag.key}
title={eintrag.beschreibung}
className="inline-flex items-center gap-2 rounded-full border border-[color-mix(in_oklab,var(--fl-accent)_35%,transparent)] bg-[color-mix(in_oklab,var(--fl-accent)_8%,transparent)] px-3 py-1.5 text-sm"
>
<AchievementSymbol symbol={eintrag.symbol} className="h-4 w-4 text-[var(--fl-accent)]" />
{eintrag.name}
</li>
))}
</ul>
</section>
)}
{recent.length > 0 && ( {recent.length > 0 && (
<section className="mt-6"> <section className="mt-6">
<h2 className="mb-3 text-lg font-semibold">Zuletzt gesehen</h2> <h2 className="mb-3 text-lg font-semibold">Zuletzt gesehen</h2>
+203
View File
@@ -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<SymbolName, React.ReactNode> = {
spross: (
<>
<path d="M12 21v-8" />
<path d="M12 13c0-3 2-5 5-5 0 3-2 5-5 5Z" />
<path d="M12 16c0-2.5-1.8-4.5-4.5-4.5 0 2.5 1.8 4.5 4.5 4.5Z" />
</>
),
haken: <path d="m4 12.5 5 5L20 6.5" />,
schluessel: (
<>
<circle cx="8" cy="12" r="3.5" />
<path d="M11.5 12H21m-3 0v3.5m-3-3.5v2.5" />
</>
),
haus: <path d="M3.5 11 12 4l8.5 7M6 9.5V20h12V9.5M10 20v-5.5h4V20" />,
klappe: (
<>
<rect x="3" y="9" width="18" height="11" rx="2" />
<path d="m3.5 9 3.5-4.5 3.5 4.5m0 0L14 4.5 17.5 9" />
</>
),
planet: (
<>
<circle cx="12" cy="12" r="6" />
<path d="M3.5 14.5c5 2.5 12 2.5 17 0" />
</>
),
kompass: (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="m15.5 8.5-2 5.5-5.5 2 2-5.5Z" />
</>
),
bildschirm: (
<>
<rect x="3" y="5" width="18" height="12" rx="2" />
<path d="M8 21h8m-4-4v4" />
</>
),
filmrolle: (
<>
<circle cx="12" cy="12" r="8.5" />
<circle cx="12" cy="8.5" r="1.6" />
<circle cx="15.4" cy="13.8" r="1.6" />
<circle cx="8.6" cy="13.8" r="1.6" />
</>
),
sonne: (
<>
<circle cx="12" cy="12" r="4" />
<path d="M12 3v2m0 14v2M3 12h2m14 0h2M5.6 5.6l1.4 1.4m10 10 1.4 1.4m0-12.8-1.4 1.4m-10 10-1.4 1.4" />
</>
),
kalender: (
<>
<rect x="3.5" y="5" width="17" height="15" rx="2" />
<path d="M3.5 10h17M8 3v4m8-4v4" />
</>
),
laeufer: (
<>
<circle cx="14" cy="5" r="2" />
<path d="m8 21 3-6 3 2 1 5M11 15l-2-4 4-3 3 3 3 .5" />
</>
),
berg: <path d="m2.5 19 6-11 4 6.5 2.5-4 6.5 8.5Z" />,
herz: <path d="M12 20s-7.5-4.6-7.5-9.4A4.1 4.1 0 0 1 12 8a4.1 4.1 0 0 1 7.5 2.6C19.5 15.4 12 20 12 20Z" />,
kreis: (
<>
<path d="M20 12a8 8 0 1 1-3.2-6.4" />
<path d="M20 4v5h-5" />
</>
),
maske: (
<>
<path d="M4 6h7v6a3.5 3.5 0 0 1-7 0Z" />
<path d="M13 6h7v6a3.5 3.5 0 0 1-7 0Z" />
<path d="M6.5 9h2m7 0h2" />
</>
),
sanduhr: (
<>
<path d="M7 3h10M7 21h10" />
<path d="M8 3v3.5L12 12l4-5.5V3M8 21v-3.5L12 12l4 5.5V21" />
</>
),
stern: <path d="m12 3.5 2.6 5.4 5.9.8-4.3 4.1 1 5.9-5.2-2.8-5.2 2.8 1-5.9L3.5 9.7l5.9-.8Z" />,
salz: (
<>
<path d="M8.5 9h7l1 11h-9Z" />
<path d="M9.5 9V5.5h5V9M11 3h2" />
</>
),
scheibe: (
<>
<circle cx="12" cy="12" r="8.5" />
<circle cx="12" cy="12" r="2.5" />
</>
),
besen: (
<>
<path d="M14 3 8.5 12.5" />
<path d="m5 14 6-3.5 4 6.5-6 3.5Z" />
<path d="m9 20.5 2.5 0.5m2-2 2.5.5m-.5-3.5 2.5.5" />
</>
),
mond: <path d="M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5Z" />,
spinne: (
<>
<circle cx="12" cy="12" r="3" />
<path d="M12 9V5m0 10v4m-3-7H5m14 0h-4M9.5 9.5 6.5 6.5m8 3 3-3m-8 8-3 3m8-3 3 3" />
</>
),
};
export function AchievementSymbol({
symbol,
className,
}: {
symbol: SymbolName;
className?: string;
}) {
return (
<svg
viewBox="0 0 24 24"
className={clsx("h-6 w-6", className)}
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
{PFADE[symbol]}
</svg>
);
}
export function AchievementBadge({
name,
beschreibung,
symbol,
unlockedAt,
}: {
name: string;
beschreibung: string;
symbol: SymbolName;
unlockedAt: string | null;
}) {
const erreicht = unlockedAt !== null;
return (
<li
className={clsx(
"flex items-start gap-3 rounded-xl border p-3 transition-colors",
erreicht
? "border-[color-mix(in_oklab,var(--fl-accent)_35%,transparent)] bg-[color-mix(in_oklab,var(--fl-accent)_7%,transparent)]"
: "border-[var(--fl-border)]",
)}
>
<span
className={clsx("mt-0.5 shrink-0", erreicht ? "text-[var(--fl-accent)]" : "text-[var(--fl-faint)]")}
style={erreicht ? { filter: "drop-shadow(0 0 6px color-mix(in oklab, var(--fl-accent) 40%, transparent))" } : undefined}
>
<AchievementSymbol symbol={symbol} />
</span>
<div className="min-w-0">
<p className={clsx("font-medium", !erreicht && "text-[var(--fl-muted)]")}>{name}</p>
<p className="text-xs text-[var(--fl-faint)]">{beschreibung}</p>
<p className="mt-0.5 text-xs text-[var(--fl-faint)]">
{erreicht ? `erreicht am ${formatiere(unlockedAt)}` : "noch offen"}
</p>
</div>
</li>
);
}
function formatiere(wert: string): string {
return new Date(wert).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
+16 -3
View File
@@ -16,7 +16,7 @@ import type { ActionResult } from "@/app/actions/library";
type Toast = { type Toast = {
id: number; id: number;
message: string; message: string;
tone: "info" | "error"; tone: "info" | "error" | "erfolg";
undo?: () => Promise<void> | void; undo?: () => Promise<void> | void;
}; };
@@ -58,6 +58,12 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
show(result.message, { show(result.message, {
undo: undo && ids.length > 0 ? () => void undo(ids) : undefined, 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], [show],
); );
@@ -96,8 +102,15 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }
borderColor: borderColor:
toast.tone === "error" toast.tone === "error"
? "color-mix(in oklab, var(--fl-danger) 50%, transparent)" ? "color-mix(in oklab, var(--fl-danger) 50%, transparent)"
: "var(--fl-border)", : toast.tone === "erfolg"
color: toast.tone === "error" ? "var(--fl-danger)" : "var(--fl-text)", ? "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)",
}} }}
> >
<span className="flex-1">{toast.message}</span> <span className="flex-1">{toast.message}</span>
+18 -8
View File
@@ -100,6 +100,7 @@ export function ProfileForm({
email: string; email: string;
region: string; region: string;
publicProfile: boolean; publicProfile: boolean;
showAchievements: boolean;
profileSlug: string | null; profileSlug: string | null;
notifyNewEpisode: boolean; notifyNewEpisode: boolean;
notifyRelease: boolean; notifyRelease: boolean;
@@ -121,6 +122,7 @@ export function ProfileForm({
name: state.name, name: state.name,
region: state.region, region: state.region,
publicProfile: state.publicProfile, publicProfile: state.publicProfile,
showAchievements: state.showAchievements,
profileSlug: state.profileSlug || null, profileSlug: state.profileSlug || null,
notifyNewEpisode: state.notifyNewEpisode, notifyNewEpisode: state.notifyNewEpisode,
notifyRelease: state.notifyRelease, notifyRelease: state.notifyRelease,
@@ -190,14 +192,22 @@ export function ProfileForm({
hint="Eine schlichte Seite mit deinen Zahlen und zuletzt gesehenen Titeln — ohne Notizen." hint="Eine schlichte Seite mit deinen Zahlen und zuletzt gesehenen Titeln — ohne Notizen."
/> />
{state.publicProfile && ( {state.publicProfile && (
<Field <>
id="profileSlug" <Field
label="Adresse" id="profileSlug"
value={state.profileSlug ?? ""} label="Adresse"
onChange={(event) => setState({ ...state, profileSlug: event.target.value })} value={state.profileSlug ?? ""}
placeholder="dein-name" onChange={(event) => setState({ ...state, profileSlug: event.target.value })}
hint="Erreichbar unter /u/<adresse>" placeholder="dein-name"
/> hint="Erreichbar unter /u/<adresse>"
/>
<Toggle
checked={state.showAchievements}
onChange={(value) => setState({ ...state, showAchievements: value })}
label="Errungenschaften zeigen"
hint="Die erreichten Abzeichen erscheinen auf der Profilseite."
/>
</>
)} )}
</fieldset> </fieldset>
)} )}
+69
View File
@@ -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/<id>`.
*
* ## 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<number, KuratierteReihe> = {
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;
+668
View File
@@ -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<string>;
jahrzehnte: Set<number>;
/** 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<number, string>;
/** Vollständig abgeschlossene Universen: Kennung → Name. */
universen: Map<string, string>;
/** Universen, die über eine eigene Reihenfolge abgeschlossen wurden. */
eigeneReihenfolge: boolean;
};
async function sammle(userId: string): Promise<Datensatz> {
// 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<string, number>();
const spanneProTitel = new Map<string, { erste: Date; letzte: Date }>();
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<string>();
const jahrzehnte = new Set<number>();
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<string, number>();
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<number, string>();
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<number, { id: string; name: string | null }[]>();
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<string, string>();
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<Ergebnis> {
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<void> {
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<AchievementView[]> {
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<AchievementView[]> {
return (await listAchievements(userId)).filter((abzeichen) => abzeichen.unlockedAt !== null);
}
+15
View File
@@ -4,6 +4,7 @@ import { env } from "./env";
import { importSeries, importMovie, SPECIALS_SEASON } from "./catalog"; import { importSeries, importMovie, SPECIALS_SEASON } from "./catalog";
import { notifyUser } from "./notify"; import { notifyUser } from "./notify";
import { syncAllConnections } from "./mediaserver"; import { syncAllConnections } from "./mediaserver";
import { evaluateAllAchievements } from "./achievements";
import { episodeCode } from "./labels"; import { episodeCode } from "./labels";
/** /**
@@ -29,6 +30,7 @@ export type SyncStats = {
sessionsPurged: number; sessionsPurged: number;
mediaServersSynced: number; mediaServersSynced: number;
mediaServerWatches: number; mediaServerWatches: number;
achievementsUnlocked: number;
errors: string[]; errors: string[];
}; };
@@ -237,6 +239,7 @@ export async function runSync({ limit = 60 }: { limit?: number } = {}): Promise<
sessionsPurged: 0, sessionsPurged: 0,
mediaServersSynced: 0, mediaServersSynced: 0,
mediaServerWatches: 0, mediaServerWatches: 0,
achievementsUnlocked: 0,
errors: [], 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(); const purged = await purgeExpired();
stats.entriesPurged = purged.entries; stats.entriesPurged = purged.entries;
stats.sessionsPurged = purged.sessions; stats.sessionsPurged = purged.sessions;
+254
View File
@@ -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 } });
}
});