mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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:
1 parent
c41f9a4307
commit
23514b4800
2 files changed
+120
-1
No files matched your search
@@ -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 & 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<");
|
||||
});
|
||||
});
|
||||
@@ -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={
|
||||
|
||||
Reference in new issue
Block a user