feat(achievements): page renders unlocked + locked + hidden achievements with progress

Adds an opaque placeholder card for the new hidden Easter-eggs.
Locked hidden achievements paint a `<HelpCircle>` 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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 10:34:32 +02:00
1 parent c41f9a4307
commit 23514b4800
2 files changed
+120 -1

No files matched your search

+69 -1
View File
@@ -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("<AchievementsPage>", () => {
expect(html).toContain("Konto &amp; 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<");
});
});
+51
View File
@@ -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<string, LucideIcon> = {
Activity,
Flame,
CalendarCheck,
CalendarDays,
FileText,
Pill,
ShieldCheck,
Target,
@@ -52,9 +63,15 @@ const iconMap: Record<string, LucideIcon> = {
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 (
<div
data-slot="achievement-card-hidden"
aria-label={t("achievements.hiddenCard.ariaLabel")}
className="border-border bg-card/40 rounded-xl border p-3 opacity-70"
>
<div className="mb-2 flex items-start justify-between gap-2">
<div className="flex items-center gap-3">
<div className="bg-muted text-muted-foreground rounded-lg p-2">
<HelpCircle className="h-3.5 w-3.5" />
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold">
{t("achievements.hiddenCard.title")}
</h3>
<p className="text-muted-foreground line-clamp-2 text-xs">
{t("achievements.hiddenCard.description")}
</p>
</div>
</div>
<Badge variant="secondary" className="shrink-0">
<Lock className="mr-1 h-3 w-3" aria-hidden="true" />
{t("achievements.locked")}
</Badge>
</div>
</div>
);
}
return (
<div
data-slot={