From 23514b4800c27f20037c377c3f91f8365fbb411d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 10:24:48 +0200 Subject: [PATCH] feat(achievements): page renders unlocked + locked + hidden achievements with progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opaque placeholder card for the new hidden Easter-eggs. Locked hidden achievements paint a `` icon and use the "Hidden achievement" / "Keep tracking — you might just stumble across it." copy from i18n; the real title, description, metric and target are never written to the DOM so a curious user can't pop the source panel to learn the trigger. Once unlocked, the real strings reveal themselves on the same card with the standard unlocked styling. Adds the new lucide icons used by the v1.4.18 badges (Smile, Scale, Moon, Sun, Sparkles, FileText, Languages, CalendarDays). Extends `CATEGORY_LABEL_KEY` with the new mood + hidden buckets so the discriminated-union check passes. Page tests guard: - locked hidden achievements paint the placeholder, not the real title/description (regression: hidden cannot leak) - unlocked hidden achievements paint the real title (the reward) - hidden category appears under its own localized heading in EN+DE - the `data-slot="achievement-card-hidden"` selector exists for E2E Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/achievements/__tests__/page.test.tsx | 70 +++++++++++++++++++- src/app/achievements/page.tsx | 51 ++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) 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 (