Errungenschaften: Abzeichen fuer erreichte Meilensteine
CI / pruefen (pull_request) Canceled after 0s
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:
+33
-10
@@ -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 });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -24,6 +24,7 @@ export default async function SettingsPage() {
|
||||
region: true,
|
||||
publicProfile: true,
|
||||
profileSlug: true,
|
||||
showAchievements: true,
|
||||
notifyNewEpisode: true,
|
||||
notifyRelease: true,
|
||||
notifyAvailability: true,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user