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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user