diff --git a/src/app/achievements/__tests__/page.test.tsx b/src/app/achievements/__tests__/page.test.tsx index 051162b8d..4fbcd58a5 100644 --- a/src/app/achievements/__tests__/page.test.tsx +++ b/src/app/achievements/__tests__/page.test.tsx @@ -91,6 +91,38 @@ const mockData = { completedAt: null, isHidden: false, }, + { + id: "hidden-night-owl", + metric: "nightOwlCount", + category: "hidden", + titleKey: "achievements.badges.hiddenNightOwl.title", + descriptionKey: "achievements.badges.hiddenNightOwl.description", + icon: "Moon", + format: "count", + target: 1, + current: 0, + points: 25, + unlocked: false, + progressPercent: 0, + completedAt: null, + isHidden: true, + }, + { + id: "hidden-doctor-pdf", + metric: "doctorPdfCount", + category: "hidden", + titleKey: "achievements.badges.hiddenDoctorPdf.title", + descriptionKey: "achievements.badges.hiddenDoctorPdf.description", + icon: "FileText", + format: "count", + target: 1, + current: 1, + points: 35, + unlocked: true, + progressPercent: 100, + completedAt: "2026-04-30T10:00:00.000Z", + isHidden: true, + }, ] satisfies AchievementProgress[], metrics: { totalTakenIntakes: 1, @@ -144,7 +176,11 @@ beforeEach(() => { describe("groupByCategory", () => { it("buckets achievements by category and orders unlocked first", () => { const grouped = groupByCategory(mockData.achievements); - expect(grouped.map((g) => g.category)).toEqual(["medication", "security"]); + expect(grouped.map((g) => g.category)).toEqual([ + "medication", + "security", + "hidden", + ]); const medication = grouped.find((g) => g.category === "medication"); expect(medication?.items.map((i) => i.id)).toEqual([ @@ -212,4 +248,36 @@ describe("", () => { expect(html).toContain("Konto & Sicherheit"); expect(html).toContain("Gesperrt"); // German "Locked" }); + + it("renders a locked hidden achievement as an opaque placeholder", () => { + const html = render(); + // The opaque card renders the placeholder string + expect(html).toContain("Hidden achievement"); + // Critically, the locked hidden achievement's title and description + // must NOT leak into the DOM (this is the whole point of "hidden"). + expect(html).not.toContain("Night owl"); + expect(html).not.toContain("Logged an entry between 02:00 and 04:00"); + // And neither does its trigger metric. + expect(html).not.toContain("nightOwlCount"); + }); + + it("renders an unlocked hidden achievement with its real title", () => { + const html = render(); + // The unlocked hidden one ("hidden-doctor-pdf") should now reveal + // its strings — that's the reward for unlocking. + expect(html).toContain("House call"); + }); + + it("flags hidden cards with the achievement-card-hidden data slot", () => { + const html = render(); + expect(html).toContain('data-slot="achievement-card-hidden"'); + }); + + it("hidden category appears under its own localized heading", () => { + const html = render(); + // English label + expect(html).toContain(">Hidden<"); + const htmlDe = render("de"); + expect(htmlDe).toContain(">Versteckt<"); + }); }); diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx index fda1590ee..2111c6e80 100644 --- a/src/app/achievements/page.tsx +++ b/src/app/achievements/page.tsx @@ -6,17 +6,26 @@ import { AlertTriangle, Bug, CalendarCheck, + CalendarDays, + FileText, Fingerprint, Flame, + HelpCircle, Heart, KeyRound, + Languages, Loader2, Lock, LogIn, + Moon, Pill, + Scale, ShieldCheck, SkipForward, + Smile, + Sparkles, Star, + Sun, Target, Trophy, type LucideIcon, @@ -45,6 +54,8 @@ const iconMap: Record = { Activity, Flame, CalendarCheck, + CalendarDays, + FileText, Pill, ShieldCheck, Target, @@ -52,9 +63,15 @@ const iconMap: Record = { Heart, KeyRound, Fingerprint, + Languages, LogIn, Bug, AlertTriangle, + Moon, + Scale, + Smile, + Sparkles, + Sun, SkipForward, }; @@ -122,6 +139,40 @@ function AchievementCard({ achievement, t }: AchievementCardProps) { const Icon = iconMap[achievement.icon] ?? Star; const unlocked = achievement.unlocked; + // v1.4.18 — locked hidden achievements paint an opaque placeholder. + // Critically, the actual title / description / metric / target are + // never rendered to the DOM so the user can't peek into the source + // to learn the trigger. Once unlocked the real strings appear. + if (achievement.isHidden && !unlocked) { + return ( +
+
+
+
+ +
+
+

+ {t("achievements.hiddenCard.title")} +

+

+ {t("achievements.hiddenCard.description")} +

+
+
+ + +
+
+ ); + } + return (