Errungenschaften: Abzeichen fuer erreichte Meilensteine
CI / pruefen (pull_request) Canceled after 0s

Zwanzig feste Abzeichen plus dynamische fuer vollstaendig gesehene Filmreihen
und abgeschlossene Universen. Sie tauchen auf, wenn etwas erreicht ist, und
bleiben — auch wenn die Bedingung spaeter wegfaellt. Eine zurueckgenommene
Sichtung soll kein Abzeichen kosten.

Alle Regeln arbeiten auf *einem* Datensatz, der einmal geholt wird. Sonst
stellte jede Regel ihre eigene Abfrage, und aus zwanzig Abzeichen wuerden
zwanzig Abfragen bei jedem Abhaken. Ausgewertet wird nach Sichtungsaktionen
und einmal taeglich; der erste Lauf eines Kontos ist stumm, weil eine
gewachsene Bibliothek sonst zwei Dutzend Meldungen auf einmal ausloest.

Fuer Filmreihen braucht es keine gepflegte Liste: TMDB gruppiert sie selbst,
und tmdbCollectionId liegt beim Import schon am Titel. Eigennamen wie
"Spider Bro" kommen aus einer kleinen Datendatei obendrauf; fehlt ein Eintrag,
greift der generische Name.

Die kuratierten Eintraege tragen jeweils ein Stueck des erwarteten
TMDB-Namens. Ich habe die Collection-Kennungen aus dem Gedaechtnis
geschrieben und kann sie hier nicht gegen TMDB pruefen — passt der Name
nicht, wird der Eintrag ignoriert und der generische greift. Eine falsch
abgeschriebene Kennung bleibt damit folgenlos statt jemandem das falsche
Abzeichen zu geben.

Die Uhrzeit-Regel liest die Stunde ueber formatToParts statt ueber die
formatierte Zeichenkette: auf Deutsch liefert Intl fuer eine einzelne Stunde
"03 Uhr", und daraus wird beim Umwandeln NaN. Ein Test hat das aufgedeckt.

Beim ersten Oeffnen der Uebersicht wird einmal ausgewertet — sonst bliebe sie
bei einem Bestandskonto leer, bis nachts der Hintergrundlauf kommt.

Zehn Tests, Anleitung mit Screenshot. Symbole als eigene SVG auf einem
gemeinsamen Raster; erreicht und offen unterscheiden sich nicht nur durch
Farbe, sondern auch im Text.

Refs #15
This commit is contained in:
Florian Wilde
2026-09-02 12:09:51 +02:00
parent 3671fbdc40
commit b560ee9c51
21 changed files with 1457 additions and 22 deletions
+33 -10
View File
@@ -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<ActionResult> {
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 });
}
// ---------------------------------------------------------------------------
+1
View File
@@ -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()
+1
View File
@@ -24,6 +24,7 @@ export default async function SettingsPage() {
region: true,
publicProfile: true,
profileSlug: true,
showAchievements: true,
notifyNewEpisode: true,
notifyRelease: true,
notifyAvailability: true,
+51
View File
@@ -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 (
<AppShell user={user} instanceName={settings.instanceName}>
@@ -205,6 +214,48 @@ export default async function StatsPage({
emptyText="Noch nichts bewertet."
/>
</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>
);
}
+23 -1
View File
@@ -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 })
</div>
</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 && (
<section className="mt-6">
<h2 className="mb-3 text-lg font-semibold">Zuletzt gesehen</h2>